From 0ec423e65c0b49444b73dcb6666c3b6853636f76 Mon Sep 17 00:00:00 2001 From: choco Date: Sun, 21 Dec 2025 22:34:39 +0100 Subject: [PATCH 01/87] potential resolve disposal crashes and race conditions --- .../LightFinder/LightFinderScannerService.cs | 52 ++++++++++++++++--- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/LightlessSync/Services/LightFinder/LightFinderScannerService.cs b/LightlessSync/Services/LightFinder/LightFinderScannerService.cs index 52ff1dc..35c7d0b 100644 --- a/LightlessSync/Services/LightFinder/LightFinderScannerService.cs +++ b/LightlessSync/Services/LightFinder/LightFinderScannerService.cs @@ -1,4 +1,4 @@ -using Dalamud.Plugin.Services; +using Dalamud.Plugin.Services; using LightlessSync.API.Dto.User; using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Mediator; @@ -33,6 +33,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase private const int _maxQueueSize = 100; private volatile bool _batchRunning = false; + private volatile bool _disposed = false; public IReadOnlyDictionary BroadcastCache => _broadcastCache; public readonly record struct BroadcastEntry(bool IsBroadcasting, DateTime ExpiryTime, string? GID); @@ -63,6 +64,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase public void Update() { + if (_disposed) + return; + _frameCounter++; var lookupsThisFrame = 0; @@ -104,7 +108,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase private async Task BatchUpdateBroadcastCacheAsync(List cids) { + if (_disposed) + return; + var results = await _broadcastService.AreUsersBroadcastingAsync(cids).ConfigureAwait(false); + + if (_disposed) + return; + var now = DateTime.UtcNow; foreach (var (cid, info) in results) @@ -123,6 +134,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase (_, old) => new BroadcastEntry(info.IsBroadcasting, expiry, info.GID)); } + if (_disposed) + return; + var activeCids = _broadcastCache .Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now) .Select(e => e.Key) @@ -134,6 +148,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg) { + if (_disposed) + return; + if (!msg.Enabled) { _broadcastCache.Clear(); @@ -147,6 +164,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase private void UpdateSyncshellBroadcasts() { + if (_disposed) + return; + var now = DateTime.UtcNow; var newSet = _broadcastCache .Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID)) @@ -230,17 +250,35 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase protected override void Dispose(bool disposing) { + _disposed = true; base.Dispose(disposing); _framework.Update -= OnFrameworkUpdate; - if (_cleanupTask != null) + + try { - _cleanupTask?.Wait(100, _cleanupCts.Token); + _cleanupCts.Cancel(); + } + catch (ObjectDisposedException) + { + // Already disposed, can be ignored :) } - _cleanupCts.Cancel(); - _cleanupCts.Dispose(); + try + { + _cleanupTask?.Wait(100); + } + catch (Exception) + { + // Task may have already completed or been cancelled? + } - _cleanupTask?.Wait(100); - _cleanupCts.Dispose(); + try + { + _cleanupCts.Dispose(); + } + catch (ObjectDisposedException) + { + // Already disposed, ignore + } } } -- 2.49.1 From 4e03b381dc2a9cab6e90297adfdc5b39d28aa7e9 Mon Sep 17 00:00:00 2001 From: choco Date: Tue, 23 Dec 2025 00:48:47 +0100 Subject: [PATCH 02/87] animated header main menu redesign test --- LightlessAPI | 2 +- LightlessSync/UI/CompactUI.cs | 9 +++++++++ LightlessSync/UI/Style/MainStyle.cs | 7 ++++--- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/LightlessAPI b/LightlessAPI index 8e4432a..5656600 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit 8e4432af45c1955436afe309c93e019577ad10e5 +Subproject commit 56566003e0e93bba05dcef49fd3ce23c6a204d81 diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index b1195b4..2c9e112 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -68,6 +68,7 @@ public class CompactUi : WindowMediatorSubscriberBase private bool _wasOpen; private float _windowContentWidth; private readonly SeluneBrush _seluneBrush = new(); + private readonly AnimatedHeader _animatedHeader = new() { Height = 120f, EnableBottomGradient = false }; private const float _connectButtonHighlightThickness = 14f; private Pair? _focusedPair; private Pair? _pendingFocusPair; @@ -153,6 +154,7 @@ public class CompactUi : WindowMediatorSubscriberBase public override void OnClose() { ForceReleaseFocus(); + _animatedHeader.ClearParticles(); base.OnClose(); } @@ -164,6 +166,13 @@ public class CompactUi : WindowMediatorSubscriberBase using var selune = Selune.Begin(_seluneBrush, drawList, windowPos, windowSize); _windowContentWidth = UiSharedService.GetWindowContentRegionWidth(); + + // Draw animated header background (just the gradient/particles, content drawn by existing methods) + var startCursorY = ImGui.GetCursorPosY(); + _animatedHeader.Draw(_windowContentWidth, (_, _) => { }); + // Reset cursor to draw content on top of the header background + ImGui.SetCursorPosY(startCursorY); + if (!_apiController.IsCurrentVersion) { var ver = _apiController.CurrentClientVersion; diff --git a/LightlessSync/UI/Style/MainStyle.cs b/LightlessSync/UI/Style/MainStyle.cs index 3da7455..53dd682 100644 --- a/LightlessSync/UI/Style/MainStyle.cs +++ b/LightlessSync/UI/Style/MainStyle.cs @@ -40,9 +40,10 @@ internal static class MainStyle new("color.frameBg", "Frame Background", () => Rgba(40, 40, 40, 255), ImGuiCol.FrameBg), new("color.frameBgHovered", "Frame Background (Hover)", () => Rgba(50, 50, 50, 100), ImGuiCol.FrameBgHovered), new("color.frameBgActive", "Frame Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.FrameBgActive), - new("color.titleBg", "Title Background", () => Rgba(24, 24, 24, 232), ImGuiCol.TitleBg), - new("color.titleBgActive", "Title Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.TitleBgActive), - new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(27, 27, 27, 255), ImGuiCol.TitleBgCollapsed), + new("color.titleBg", "Title Background", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBg), + new("color.titleBgActive", "Title Background (Active)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgActive), + new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgCollapsed), + new("color.menuBarBg", "Menu Bar Background", () => Rgba(36, 36, 36, 255), ImGuiCol.MenuBarBg), new("color.scrollbarBg", "Scrollbar Background", () => Rgba(0, 0, 0, 0), ImGuiCol.ScrollbarBg), new("color.scrollbarGrab", "Scrollbar Grab", () => Rgba(62, 62, 62, 255), ImGuiCol.ScrollbarGrab), -- 2.49.1 From 5b81caf5a84ebe52e2b9b2db70b1dac83ce442f5 Mon Sep 17 00:00:00 2001 From: choco Date: Tue, 23 Dec 2025 17:16:51 +0100 Subject: [PATCH 03/87] compact menu redesign with new animated particle header, enable particles toggle added in UI settings --- .../Configurations/LightlessConfig.cs | 1 + LightlessSync/UI/CompactUI.cs | 189 ++++++++---------- LightlessSync/UI/SettingsUi.cs | 9 +- LightlessSync/UI/Style/AnimatedHeader.cs | 4 +- LightlessSync/UI/UpdateNotesUi.cs | 4 +- 5 files changed, 102 insertions(+), 105 deletions(-) diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index 9b4055b..4987cc1 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -154,4 +154,5 @@ public class LightlessConfig : ILightlessConfiguration public bool SyncshellFinderEnabled { get; set; } = false; public string? SelectedFinderSyncshell { get; set; } = null; public string LastSeenVersion { get; set; } = string.Empty; + public bool EnableParticleEffects { get; set; } = true; } diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index 2c9e112..ec2ff0d 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -68,7 +68,7 @@ public class CompactUi : WindowMediatorSubscriberBase private bool _wasOpen; private float _windowContentWidth; private readonly SeluneBrush _seluneBrush = new(); - private readonly AnimatedHeader _animatedHeader = new() { Height = 120f, EnableBottomGradient = false }; + private readonly AnimatedHeader _animatedHeader = new(); private const float _connectButtonHighlightThickness = 14f; private Pair? _focusedPair; private Pair? _pendingFocusPair; @@ -128,6 +128,11 @@ public class CompactUi : WindowMediatorSubscriberBase .Apply(); _drawFolders = [.. DrawFolders]; + + _animatedHeader.Height = 120f; + _animatedHeader.EnableBottomGradient = true; + _animatedHeader.GradientHeight = 250f; + _animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects; #if DEBUG string dev = "Dev Build"; @@ -218,17 +223,11 @@ public class CompactUi : WindowMediatorSubscriberBase } using (ImRaii.PushId("header")) DrawUIDHeader(); - _uiSharedService.RoundedSeparator(UIColors.Get("LightlessPurple"), 2.5f, 1f, 12f); - using (ImRaii.PushId("serverstatus")) - { - DrawServerStatus(); - } selune.DrawHighlightOnly(ImGui.GetIO().DeltaTime); var style = ImGui.GetStyle(); var contentMinY = windowPos.Y + ImGui.GetWindowContentRegionMin().Y; var gradientInset = 4f * ImGuiHelpers.GlobalScale; var gradientTop = MathF.Max(contentMinY, ImGui.GetCursorScreenPos().Y - style.ItemSpacing.Y + gradientInset); - ImGui.Separator(); if (_apiController.ServerState is ServerState.Connected) { @@ -236,7 +235,6 @@ public class CompactUi : WindowMediatorSubscriberBase using (ImRaii.PushId("global-topmenu")) _tabMenu.Draw(pairSnapshot); using (ImRaii.PushId("pairlist")) DrawPairs(); - ImGui.Separator(); var transfersTop = ImGui.GetCursorScreenPos().Y; var gradientBottom = MathF.Max(gradientTop, transfersTop - style.ItemSpacing.Y - gradientInset); selune.DrawGradient(gradientTop, gradientBottom, ImGui.GetIO().DeltaTime); @@ -317,95 +315,6 @@ public class CompactUi : WindowMediatorSubscriberBase ImGui.EndChild(); } - private void DrawServerStatus() - { - var buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Link); - var userCount = _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture); - var userSize = ImGui.CalcTextSize(userCount); - var textSize = ImGui.CalcTextSize("Users Online"); -#if DEBUG - string shardConnection = $"Shard: {_apiController.ServerInfo.ShardName}"; -#else - string shardConnection = string.Equals(_apiController.ServerInfo.ShardName, "Main", StringComparison.OrdinalIgnoreCase) ? string.Empty : $"Shard: {_apiController.ServerInfo.ShardName}"; -#endif - var shardTextSize = ImGui.CalcTextSize(shardConnection); - var printShard = !string.IsNullOrEmpty(_apiController.ServerInfo.ShardName) && shardConnection != string.Empty; - - if (_apiController.ServerState is ServerState.Connected) - { - ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth()) / 2 - (userSize.X + textSize.X) / 2 - ImGui.GetStyle().ItemSpacing.X / 2); - if (!printShard) ImGui.AlignTextToFramePadding(); - ImGui.TextColored(UIColors.Get("LightlessPurple"), userCount); - ImGui.SameLine(); - if (!printShard) ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted("Users Online"); - } - else - { - ImGui.AlignTextToFramePadding(); - ImGui.TextColored(UIColors.Get("DimRed"), "Not connected to any server"); - } - - if (printShard) - { - ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ImGui.GetStyle().ItemSpacing.Y); - ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth()) / 2 - shardTextSize.X / 2); - ImGui.TextUnformatted(shardConnection); - } - - ImGui.SameLine(); - if (printShard) - { - ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ((userSize.Y + textSize.Y) / 2 + shardTextSize.Y) / 2 - ImGui.GetStyle().ItemSpacing.Y + buttonSize.Y / 2); - } - bool isConnectingOrConnected = _apiController.ServerState is ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting; - var color = UiSharedService.GetBoolColor(!isConnectingOrConnected); - var connectedIcon = isConnectingOrConnected ? FontAwesomeIcon.Unlink : FontAwesomeIcon.Link; - - ImGui.SameLine(ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth() - buttonSize.X); - if (printShard) - { - ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ((userSize.Y + textSize.Y) / 2 + shardTextSize.Y) / 2 - ImGui.GetStyle().ItemSpacing.Y + buttonSize.Y / 2); - } - - if (_apiController.ServerState is not (ServerState.Reconnecting or ServerState.Disconnecting)) - { - using (ImRaii.PushColor(ImGuiCol.Text, color)) - { - if (_uiSharedService.IconButton(connectedIcon)) - { - if (isConnectingOrConnected && !_serverManager.CurrentServer.FullPause) - { - _serverManager.CurrentServer.FullPause = true; - _serverManager.Save(); - } - else if (!isConnectingOrConnected && _serverManager.CurrentServer.FullPause) - { - _serverManager.CurrentServer.FullPause = false; - _serverManager.Save(); - } - - _ = _apiController.CreateConnectionsAsync(); - } - } - - if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) - { - Selune.RegisterHighlight( - ImGui.GetItemRectMin(), - ImGui.GetItemRectMax(), - SeluneHighlightMode.Both, - borderOnly: true, - borderThicknessOverride: _connectButtonHighlightThickness, - exactSize: true, - clipToElement: true, - roundingOverride: ImGui.GetStyle().FrameRounding); - } - - UiSharedService.AttachToolTip(isConnectingOrConnected ? "Disconnect from " + _serverManager.CurrentServer.ServerName : "Connect to " + _serverManager.CurrentServer.ServerName); - } - } - private void DrawTransfers() { var currentUploads = _fileTransferManager.GetCurrentUploadsSnapshot(); @@ -541,8 +450,7 @@ public class CompactUi : WindowMediatorSubscriberBase using (_uiSharedService.IconFont.Push()) iconSize = ImGui.CalcTextSize(FontAwesomeIcon.PersonCirclePlus.ToIconString()); - float contentWidth = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X; - float uidStartX = (contentWidth - uidTextSize.X) / 2f; + float uidStartX = 25f; float cursorY = ImGui.GetCursorPosY(); if (_configService.Current.BroadcastEnabled && _apiController.IsConnected) @@ -671,6 +579,12 @@ public class CompactUi : WindowMediatorSubscriberBase UiSharedService.AttachToolTip("Click to copy"); + // Connect/Disconnect button next to big UID + DrawConnectButton(cursorY, uidTextSize.Y); + + // Add spacing below the big UID + ImGuiHelpers.ScaledDummy(5f); + if (_apiController.ServerState is ServerState.Connected && analysisSummary.HasData) { var objectSummary = analysisSummary.Objects.Values.FirstOrDefault(summary => summary.HasEntries); @@ -717,10 +631,12 @@ public class CompactUi : WindowMediatorSubscriberBase ImGui.SetClipboardText(_apiController.DisplayName); } - if (!string.Equals(_apiController.DisplayName, _apiController.UID, StringComparison.Ordinal)) + // Only show smaller UID line if DisplayName differs from UID (custom vanity name) + bool hasCustomName = !string.Equals(_apiController.DisplayName, _apiController.UID, StringComparison.OrdinalIgnoreCase); + + if (hasCustomName) { - var origTextSize = ImGui.CalcTextSize(_apiController.UID); - ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X) / 2 - (origTextSize.X / 2)); + ImGui.SetCursorPosX(uidStartX); if (useVanityColors) { @@ -755,14 +671,83 @@ public class CompactUi : WindowMediatorSubscriberBase { ImGui.SetClipboardText(_apiController.UID); } + + // Users Online on same line as smaller UID (with separator) + ImGui.SameLine(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("|"); + ImGui.SameLine(); + ImGui.TextColored(UIColors.Get("LightlessGreen"), _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture)); + ImGui.SameLine(); + ImGui.TextUnformatted("Users Online"); + } + else + { + // No custom name - just show Users Online aligned to uidStartX + ImGui.SetCursorPosX(uidStartX); + ImGui.TextColored(UIColors.Get("LightlessGreen"), _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture)); + ImGui.SameLine(); + ImGui.TextUnformatted("Users Online"); } } else { + ImGui.SetCursorPosX(uidStartX); UiSharedService.ColorTextWrapped(_apiController.ServerState.GetServerError(_apiController.AuthFailureMessage), uidColor); } } + private void DrawConnectButton(float cursorY, float buttonHeight) + { + var buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Link); + bool isConnectingOrConnected = _apiController.ServerState is ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting; + var color = UiSharedService.GetBoolColor(!isConnectingOrConnected); + var connectedIcon = isConnectingOrConnected ? FontAwesomeIcon.Unlink : FontAwesomeIcon.Link; + + // Position next to big UID on the right side + + if (_apiController.ServerState is not (ServerState.Reconnecting or ServerState.Disconnecting)) + { + ImGui.SetCursorPosX(UiSharedService.GetWindowContentRegionWidth() - buttonSize.X - 13f); + ImGui.SetCursorPosY(buttonHeight); + + using (ImRaii.PushColor(ImGuiCol.Text, color)) + using (ImRaii.PushColor(ImGuiCol.Button, ImGui.ColorConvertFloat4ToU32(new(0, 0, 0, 0)))) + { + if (_uiSharedService.IconButton(connectedIcon, buttonSize.Y)) + { + if (isConnectingOrConnected && !_serverManager.CurrentServer.FullPause) + { + _serverManager.CurrentServer.FullPause = true; + _serverManager.Save(); + } + else if (!isConnectingOrConnected && _serverManager.CurrentServer.FullPause) + { + _serverManager.CurrentServer.FullPause = false; + _serverManager.Save(); + } + + _ = _apiController.CreateConnectionsAsync(); + } + } + + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) + { + Selune.RegisterHighlight( + ImGui.GetItemRectMin(), + ImGui.GetItemRectMax(), + SeluneHighlightMode.Both, + borderOnly: true, + borderThicknessOverride: _connectButtonHighlightThickness, + exactSize: true, + clipToElement: true, + roundingOverride: ImGui.GetStyle().FrameRounding); + } + + UiSharedService.AttachToolTip(isConnectingOrConnected ? "Disconnect from " + _serverManager.CurrentServer.ServerName : "Connect to " + _serverManager.CurrentServer.ServerName); + } + } + private IEnumerable DrawFolders { get diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 548fc75..580849e 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -2082,7 +2082,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Separator(); var openPopupOnAddition = _configService.Current.OpenPopupOnAdd; - + using (var popupTree = BeginGeneralTree("Popup & Auto Fill", UIColors.Get("LightlessPurple"))) { if (popupTree.Visible) @@ -2139,11 +2139,18 @@ public class SettingsUi : WindowMediatorSubscriberBase var groupInVisible = _configService.Current.ShowSyncshellUsersInVisible; var syncshellOfflineSeparate = _configService.Current.ShowSyncshellOfflineUsersSeparately; var greenVisiblePair = _configService.Current.ShowVisiblePairsGreenEye; + var enableParticleEffects = _configService.Current.EnableParticleEffects; using (var behaviorTree = BeginGeneralTree("Behavior", UIColors.Get("LightlessPurple"))) { if (behaviorTree.Visible) { + if (ImGui.Checkbox("Enable Particle Effects", ref enableParticleEffects)) + { + _configService.Current.EnableParticleEffects = enableParticleEffects; + _configService.Save(); + } + if (ImGui.Checkbox("Enable Game Right Click Menu Entries", ref enableRightClickMenu)) { _configService.Current.EnableRightClickMenus = enableRightClickMenu; diff --git a/LightlessSync/UI/Style/AnimatedHeader.cs b/LightlessSync/UI/Style/AnimatedHeader.cs index 15488ac..839c704 100644 --- a/LightlessSync/UI/Style/AnimatedHeader.cs +++ b/LightlessSync/UI/Style/AnimatedHeader.cs @@ -47,6 +47,8 @@ public class AnimatedHeader public Vector4 BottomColor { get; set; } = new(0.12f, 0.08f, 0.20f, 1.0f); public bool EnableParticles { get; set; } = true; public bool EnableBottomGradient { get; set; } = true; + + public float GradientHeight { get; set; } = 60f; /// /// Draws the animated header with some customizable content @@ -171,7 +173,7 @@ public class AnimatedHeader private void DrawBottomGradient(Vector2 headerStart, Vector2 headerEnd, float width) { var drawList = ImGui.GetWindowDrawList(); - var gradientHeight = 60f; + var gradientHeight = GradientHeight; for (int i = 0; i < gradientHeight; i++) { diff --git a/LightlessSync/UI/UpdateNotesUi.cs b/LightlessSync/UI/UpdateNotesUi.cs index e1b3ab3..d27e87c 100644 --- a/LightlessSync/UI/UpdateNotesUi.cs +++ b/LightlessSync/UI/UpdateNotesUi.cs @@ -40,6 +40,7 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase logger.LogInformation("UpdateNotesUi constructor called"); _uiShared = uiShared; _configService = configService; + _animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects; RespectCloseHotkey = true; ShowCloseButton = true; @@ -48,7 +49,8 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoMove; PositionCondition = ImGuiCond.Always; - + + WindowBuilder.For(this) .AllowPinning(false) .AllowClickthrough(false) -- 2.49.1 From 6c1cc77aaa5912e7c7996a08559b7c0095617dce Mon Sep 17 00:00:00 2001 From: choco Date: Tue, 23 Dec 2025 17:36:36 +0100 Subject: [PATCH 04/87] settings animated header --- LightlessSync/UI/SettingsUi.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 580849e..91f69d5 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -69,6 +69,8 @@ public class SettingsUi : WindowMediatorSubscriberBase private readonly UiSharedService _uiShared; private readonly IProgress<(int, int, FileCacheEntity)> _validationProgress; private readonly NameplateService _nameplateService; + private readonly AnimatedHeader _animatedHeader = new(); + private (int, int, FileCacheEntity) _currentProgress; private bool _deleteAccountPopupModalShown = false; private bool _deleteFilesPopupModalShown = false; @@ -203,7 +205,10 @@ public class SettingsUi : WindowMediatorSubscriberBase _nameplateService = nameplateService; _actorObjectService = actorObjectService; _validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v); - + _animatedHeader.Height = 120f; + _animatedHeader.EnableBottomGradient = true; + _animatedHeader.GradientHeight = 250f; + _animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects; WindowBuilder.For(this) .AllowPinning(true) .AllowClickthrough(false) @@ -239,6 +244,7 @@ public class SettingsUi : WindowMediatorSubscriberBase public override void OnClose() { + _animatedHeader.ClearParticles(); _uiShared.EditTrackerPosition = false; _uidToAddForIgnore = string.Empty; _secretKeysConversionCts = _secretKeysConversionCts.CancelRecreate(); @@ -253,8 +259,8 @@ public class SettingsUi : WindowMediatorSubscriberBase protected override void DrawInternal() { + _animatedHeader.Draw(ImGui.GetContentRegionAvail().X, (_, _) => { }); _ = _uiShared.DrawOtherPluginState(); - DrawSettingsContent(); } private static Vector3 PackedColorToVector3(uint color) @@ -2151,6 +2157,8 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Save(); } + _uiShared.DrawHelpText("This will enable particle effects in the UI."); + if (ImGui.Checkbox("Enable Game Right Click Menu Entries", ref enableRightClickMenu)) { _configService.Current.EnableRightClickMenus = enableRightClickMenu; -- 2.49.1 From ced72ab9eb46b1980aacd26c97c3ea57d90f53c0 Mon Sep 17 00:00:00 2001 From: choco Date: Wed, 24 Dec 2025 16:59:46 +0100 Subject: [PATCH 05/87] icon centering changes --- LightlessSync/Plugin.cs | 3 +- LightlessSync/UI/CompactUI.cs | 168 +++++++++++++++++++--------------- 2 files changed, 94 insertions(+), 77 deletions(-) diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 8198bb3..425703a 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -556,7 +556,6 @@ public sealed class Plugin : IDalamudPlugin public void Dispose() { - _host.StopAsync().GetAwaiter().GetResult(); - _host.Dispose(); + _host.StopAsync().ContinueWith(_ => _host.Dispose()).Wait(TimeSpan.FromSeconds(5)); } } diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index ec2ff0d..e297e11 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -453,17 +453,49 @@ public class CompactUi : WindowMediatorSubscriberBase float uidStartX = 25f; float cursorY = ImGui.GetCursorPosY(); + ImGui.SetCursorPosY(cursorY); + ImGui.SetCursorPosX(uidStartX); + + bool headerItemClicked; + using (_uiSharedService.UidFont.Push()) + { + if (useVanityColors) + { + var seString = SeStringUtils.BuildFormattedPlayerName(uidText, vanityTextColor, vanityGlowColor); + var cursorPos = ImGui.GetCursorScreenPos(); + var targetFontSize = ImGui.GetFontSize(); + var font = ImGui.GetFont(); + SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, targetFontSize ,font , "uid-header"); + } + else + { + ImGui.TextColored(uidColor, uidText); + } + } + + // Get the actual rendered text rect for proper icon alignment + var uidTextRect = ImGui.GetItemRectMax() - ImGui.GetItemRectMin(); + var uidTextRectMin = ImGui.GetItemRectMin(); + var uidTextHovered = ImGui.IsItemHovered(); + headerItemClicked = ImGui.IsItemClicked(); + + // Track position for icons next to UID text + // Use uidTextSize.Y (actual font height) for vertical centering, not hitbox height + float nextIconX = uidTextRectMin.X + uidTextRect.X + 10f; + float iconYOffset = (uidTextSize.Y - iconSize.Y) * 0.5f; + float textVerticalOffset = (uidTextRect.Y - uidTextSize.Y) * 0.5f; + var buttonSize = new Vector2(iconSize.X, uidTextSize.Y); + if (_configService.Current.BroadcastEnabled && _apiController.IsConnected) { - float iconYOffset = (uidTextSize.Y - iconSize.Y) * 0.5f; - var buttonSize = new Vector2(iconSize.X, uidTextSize.Y); - - ImGui.SetCursorPos(new Vector2(ImGui.GetStyle().ItemSpacing.X + 5f, cursorY)); + ImGui.SetCursorScreenPos(new Vector2(nextIconX, uidTextRectMin.Y + textVerticalOffset)); + ImGui.InvisibleButton("BroadcastIcon", buttonSize); - var iconPos = ImGui.GetItemRectMin() + new Vector2(0f, iconYOffset); using (_uiSharedService.IconFont.Push()) ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(UIColors.Get("LightlessGreen")), FontAwesomeIcon.PersonCirclePlus.ToIconString()); + + nextIconX = ImGui.GetItemRectMax().X + 6f; if (ImGui.IsItemHovered()) @@ -535,56 +567,8 @@ public class CompactUi : WindowMediatorSubscriberBase if (ImGui.IsItemClicked()) _lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); } - - ImGui.SetCursorPosY(cursorY); - ImGui.SetCursorPosX(uidStartX); - - bool headerItemClicked; - using (_uiSharedService.UidFont.Push()) - { - if (useVanityColors) - { - var seString = SeStringUtils.BuildFormattedPlayerName(uidText, vanityTextColor, vanityGlowColor); - var cursorPos = ImGui.GetCursorScreenPos(); - var targetFontSize = ImGui.GetFontSize(); - var font = ImGui.GetFont(); - SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, targetFontSize ,font , "uid-header"); - } - else - { - ImGui.TextColored(uidColor, uidText); - } - } - - if (ImGui.IsItemHovered()) - { - var padding = new Vector2(35f * ImGuiHelpers.GlobalScale); - Selune.RegisterHighlight( - ImGui.GetItemRectMin() - padding, - ImGui.GetItemRectMax() + padding, - SeluneHighlightMode.Point, - exactSize: true, - clipToElement: true, - clipPadding: padding, - highlightColorOverride: vanityGlowColor, - highlightAlphaOverride: 0.05f); - } - - headerItemClicked = ImGui.IsItemClicked(); - - if (headerItemClicked) - { - ImGui.SetClipboardText(uidText); - } - - UiSharedService.AttachToolTip("Click to copy"); - - // Connect/Disconnect button next to big UID - DrawConnectButton(cursorY, uidTextSize.Y); - - // Add spacing below the big UID - ImGuiHelpers.ScaledDummy(5f); - + + // Warning threshold icon (next to lightfinder or UID text) if (_apiController.ServerState is ServerState.Connected && analysisSummary.HasData) { var objectSummary = analysisSummary.Objects.Values.FirstOrDefault(summary => summary.HasEntries); @@ -598,24 +582,30 @@ public class CompactUi : WindowMediatorSubscriberBase if ((isOverTriHold || isOverVRAMUsage) && _playerPerformanceConfig.Current.WarnOnExceedingThresholds) { - ImGui.SameLine(); - ImGui.SetCursorPosY(cursorY + 15f); - _uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow")); + ImGui.SetCursorScreenPos(new Vector2(nextIconX, uidTextRectMin.Y + textVerticalOffset)); + + ImGui.InvisibleButton("WarningThresholdIcon", buttonSize); + var warningIconPos = ImGui.GetItemRectMin() + new Vector2(0f, iconYOffset); + using (_uiSharedService.IconFont.Push()) + ImGui.GetWindowDrawList().AddText(warningIconPos, ImGui.GetColorU32(UIColors.Get("LightlessYellow")), FontAwesomeIcon.ExclamationTriangle.ToIconString()); - string warningMessage = ""; - if (isOverTriHold) + if (ImGui.IsItemHovered()) { - warningMessage += $"You exceed your own triangles threshold by " + - $"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles."; - warningMessage += Environment.NewLine; - + string warningMessage = ""; + if (isOverTriHold) + { + warningMessage += $"You exceed your own triangles threshold by " + + $"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles."; + warningMessage += Environment.NewLine; + } + if (isOverVRAMUsage) + { + warningMessage += $"You exceed your own VRAM threshold by " + + $"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}."; + } + UiSharedService.AttachToolTip(warningMessage); } - if (isOverVRAMUsage) - { - warningMessage += $"You exceed your own VRAM threshold by " + - $"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}."; - } - UiSharedService.AttachToolTip(warningMessage); + if (ImGui.IsItemClicked()) { _lightlessMediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi))); @@ -624,6 +614,33 @@ public class CompactUi : WindowMediatorSubscriberBase } } + if (uidTextHovered) + { + var padding = new Vector2(35f * ImGuiHelpers.GlobalScale); + Selune.RegisterHighlight( + uidTextRectMin - padding, + uidTextRectMin + uidTextRect + padding, + SeluneHighlightMode.Point, + exactSize: true, + clipToElement: true, + clipPadding: padding, + highlightColorOverride: vanityGlowColor, + highlightAlphaOverride: 0.05f); + } + + if (headerItemClicked) + { + ImGui.SetClipboardText(uidText); + } + + UiSharedService.AttachToolTip("Click to copy"); + + // Connect/Disconnect button next to big UID (use screen pos to avoid affecting layout) + DrawConnectButton(uidTextRectMin.Y + textVerticalOffset, uidTextSize.Y); + + // Add spacing below the big UID + ImGuiHelpers.ScaledDummy(5f); + if (_apiController.ServerState is ServerState.Connected) { if (headerItemClicked) @@ -697,19 +714,20 @@ public class CompactUi : WindowMediatorSubscriberBase } } - private void DrawConnectButton(float cursorY, float buttonHeight) + private void DrawConnectButton(float screenY, float textHeight) { var buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Link); bool isConnectingOrConnected = _apiController.ServerState is ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting; var color = UiSharedService.GetBoolColor(!isConnectingOrConnected); var connectedIcon = isConnectingOrConnected ? FontAwesomeIcon.Unlink : FontAwesomeIcon.Link; - // Position next to big UID on the right side - + // Position on right side, vertically centered with text if (_apiController.ServerState is not (ServerState.Reconnecting or ServerState.Disconnecting)) { - ImGui.SetCursorPosX(UiSharedService.GetWindowContentRegionWidth() - buttonSize.X - 13f); - ImGui.SetCursorPosY(buttonHeight); + var windowPos = ImGui.GetWindowPos(); + var screenX = windowPos.X + UiSharedService.GetWindowContentRegionWidth() - buttonSize.X - 13f; + var yOffset = (textHeight - buttonSize.Y) * 0.5f; + ImGui.SetCursorScreenPos(new Vector2(screenX, screenY + yOffset)); using (ImRaii.PushColor(ImGuiCol.Text, color)) using (ImRaii.PushColor(ImGuiCol.Button, ImGui.ColorConvertFloat4ToU32(new(0, 0, 0, 0)))) -- 2.49.1 From f792bc19546628848fa59003c05f5f223660f96d Mon Sep 17 00:00:00 2001 From: choco Date: Fri, 26 Dec 2025 00:00:13 +0100 Subject: [PATCH 06/87] compact ui design refactor with lightfinder redesign --- LightlessSync/Plugin.cs | 9 - .../Services/Mediator/LightlessMediator.cs | 34 +- LightlessSync/UI/CompactUI.cs | 119 +- LightlessSync/UI/LightFinderUI.cs | 1501 ++++++++++++----- LightlessSync/UI/SyncshellFinderUI.cs | 850 ---------- LightlessSync/UI/TopTabMenu.cs | 36 +- .../WebAPI/Files/FileDownloadManager.cs | 14 +- 7 files changed, 1248 insertions(+), 1315 deletions(-) delete mode 100644 LightlessSync/UI/SyncshellFinderUI.cs diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 425703a..606a23d 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -459,15 +459,6 @@ public sealed class Plugin : IDalamudPlugin sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); - - services.AddScoped(sp => new SyncshellFinderUI( - sp.GetRequiredService>(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), diff --git a/LightlessSync/Services/Mediator/LightlessMediator.cs b/LightlessSync/Services/Mediator/LightlessMediator.cs index 52399e2..87e36ad 100644 --- a/LightlessSync/Services/Mediator/LightlessMediator.cs +++ b/LightlessSync/Services/Mediator/LightlessMediator.cs @@ -63,23 +63,31 @@ public sealed class LightlessMediator : IHostedService _ = Task.Run(async () => { - while (!_loopCts.Token.IsCancellationRequested) + try { - while (!_processQueue) + while (!_loopCts.Token.IsCancellationRequested) { + while (!_processQueue) + { + await Task.Delay(100, _loopCts.Token).ConfigureAwait(false); + } + await Task.Delay(100, _loopCts.Token).ConfigureAwait(false); + + HashSet processedMessages = []; + while (_messageQueue.TryDequeue(out var message)) + { + if (processedMessages.Contains(message)) { continue; } + + processedMessages.Add(message); + + ExecuteMessage(message); + } } - - await Task.Delay(100, _loopCts.Token).ConfigureAwait(false); - - HashSet processedMessages = []; - while (_messageQueue.TryDequeue(out var message)) - { - if (processedMessages.Contains(message)) { continue; } - processedMessages.Add(message); - - ExecuteMessage(message); - } + } + catch (OperationCanceledException) + { + _logger.LogInformation("LightlessMediator stopped"); } }); diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index e297e11..be56434 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -34,45 +34,65 @@ namespace LightlessSync.UI; public class CompactUi : WindowMediatorSubscriberBase { - private readonly CharacterAnalyzer _characterAnalyzer; + #region Constants + + private const float ConnectButtonHighlightThickness = 14f; + + #endregion + + #region Services + private readonly ApiController _apiController; + private readonly CharacterAnalyzer _characterAnalyzer; + private readonly DalamudUtilService _dalamudUtilService; + private readonly DrawEntityFactory _drawEntityFactory; + private readonly FileUploadManager _fileTransferManager; + private readonly IpcManager _ipcManager; + private readonly LightFinderService _broadcastService; private readonly LightlessConfigService _configService; private readonly LightlessMediator _lightlessMediator; private readonly PairLedger _pairLedger; - private readonly ConcurrentDictionary> _currentDownloads = new(); - private readonly DrawEntityFactory _drawEntityFactory; - private readonly FileUploadManager _fileTransferManager; - private readonly PlayerPerformanceConfigService _playerPerformanceConfig; private readonly PairUiService _pairUiService; - private readonly SelectTagForPairUi _selectTagForPairUi; - private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi; - private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi; - private readonly RenameSyncshellTagUi _renameSyncshellTagUi; - private readonly SelectPairForTagUi _selectPairsForGroupUi; - private readonly RenamePairTagUi _renamePairTagUi; - private readonly IpcManager _ipcManager; + private readonly PlayerPerformanceConfigService _playerPerformanceConfig; private readonly ServerConfigurationManager _serverManager; - private readonly TopTabMenu _tabMenu; private readonly TagHandler _tagHandler; private readonly UiSharedService _uiSharedService; - private readonly LightFinderService _broadcastService; - private readonly DalamudUtilService _dalamudUtilService; - + + #endregion + + #region UI Components + + private readonly AnimatedHeader _animatedHeader = new(); + private readonly RenamePairTagUi _renamePairTagUi; + private readonly RenameSyncshellTagUi _renameSyncshellTagUi; + private readonly SelectPairForTagUi _selectPairsForGroupUi; + private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi; + private readonly SelectTagForPairUi _selectTagForPairUi; + private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi; + private readonly SeluneBrush _seluneBrush = new(); + private readonly TopTabMenu _tabMenu; + + #endregion + + #region State + + private readonly ConcurrentDictionary> _currentDownloads = new(); private List _drawFolders; + private Pair? _focusedPair; private Pair? _lastAddedUser; private string _lastAddedUserComment = string.Empty; private Vector2 _lastPosition = Vector2.One; private Vector2 _lastSize = Vector2.One; + private int _pendingFocusFrame = -1; + private Pair? _pendingFocusPair; private bool _showModalForUserAddition; private float _transferPartHeight; private bool _wasOpen; private float _windowContentWidth; - private readonly SeluneBrush _seluneBrush = new(); - private readonly AnimatedHeader _animatedHeader = new(); - private const float _connectButtonHighlightThickness = 14f; - private Pair? _focusedPair; - private Pair? _pendingFocusPair; - private int _pendingFocusFrame = -1; + + #endregion + + #region Constructor public CompactUi( ILogger logger, @@ -156,6 +176,10 @@ public class CompactUi : WindowMediatorSubscriberBase _lightlessMediator = mediator; } + #endregion + + #region Lifecycle + public override void OnClose() { ForceReleaseFocus(); @@ -297,6 +321,10 @@ public class CompactUi : WindowMediatorSubscriberBase } } + #endregion + + #region Content Drawing + private void DrawPairs() { float ySize = Math.Abs(_transferPartHeight) < 0.0001f @@ -410,11 +438,9 @@ public class CompactUi : WindowMediatorSubscriberBase return new DownloadSummary(totalFiles, transferredFiles, transferredBytes, totalBytes); } - [StructLayout(LayoutKind.Auto)] - private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes) - { - public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0; - } + #endregion + + #region Header Drawing private void DrawUIDHeader() { @@ -626,6 +652,8 @@ public class CompactUi : WindowMediatorSubscriberBase clipPadding: padding, highlightColorOverride: vanityGlowColor, highlightAlphaOverride: 0.05f); + + ImGui.SetTooltip("Click to copy"); } if (headerItemClicked) @@ -633,8 +661,6 @@ public class CompactUi : WindowMediatorSubscriberBase ImGui.SetClipboardText(uidText); } - UiSharedService.AttachToolTip("Click to copy"); - // Connect/Disconnect button next to big UID (use screen pos to avoid affecting layout) DrawConnectButton(uidTextRectMin.Y + textVerticalOffset, uidTextSize.Y); @@ -756,7 +782,7 @@ public class CompactUi : WindowMediatorSubscriberBase ImGui.GetItemRectMax(), SeluneHighlightMode.Both, borderOnly: true, - borderThicknessOverride: _connectButtonHighlightThickness, + borderThicknessOverride: ConnectButtonHighlightThickness, exactSize: true, clipToElement: true, roundingOverride: ImGui.GetStyle().FrameRounding); @@ -766,6 +792,10 @@ public class CompactUi : WindowMediatorSubscriberBase } } + #endregion + + #region Folder Building + private IEnumerable DrawFolders { get @@ -901,6 +931,10 @@ public class CompactUi : WindowMediatorSubscriberBase } } + #endregion + + #region Filtering & Sorting + private static bool PassesFilter(PairUiEntry entry, string filter) { if (string.IsNullOrEmpty(filter)) return true; @@ -1044,10 +1078,11 @@ public class CompactUi : WindowMediatorSubscriberBase return SortGroupEntries(entries, group); } - private void UiSharedService_GposeEnd() - { - IsOpen = _wasOpen; - } + #endregion + + #region GPose Handlers + + private void UiSharedService_GposeEnd() => IsOpen = _wasOpen; private void UiSharedService_GposeStart() { @@ -1055,6 +1090,10 @@ public class CompactUi : WindowMediatorSubscriberBase IsOpen = false; } + #endregion + + #region Focus Tracking + private void RegisterFocusCharacter(Pair pair) { _pendingFocusPair = pair; @@ -1100,4 +1139,16 @@ public class CompactUi : WindowMediatorSubscriberBase _pendingFocusPair = null; _pendingFocusFrame = -1; } + + #endregion + + #region Helper Types + + [StructLayout(LayoutKind.Auto)] + private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes) + { + public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0; + } + + #endregion } diff --git a/LightlessSync/UI/LightFinderUI.cs b/LightlessSync/UI/LightFinderUI.cs index 22911cb..b6bfc73 100644 --- a/LightlessSync/UI/LightFinderUI.cs +++ b/LightlessSync/UI/LightFinderUI.cs @@ -1,445 +1,1188 @@ using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Colors; +using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; using Dalamud.Utility; +using LightlessSync.API.Data; +using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; +using LightlessSync.API.Dto; using LightlessSync.API.Dto.Group; using LightlessSync.LightlessConfiguration; using LightlessSync.Services; using LightlessSync.Services.LightFinder; using LightlessSync.Services.Mediator; +using LightlessSync.UI.Services; +using LightlessSync.UI.Style; +using LightlessSync.UI.Tags; using LightlessSync.Utils; using LightlessSync.WebAPI; using Microsoft.Extensions.Logging; using System.Numerics; -namespace LightlessSync.UI +namespace LightlessSync.UI; + +public class LightFinderUI : WindowMediatorSubscriberBase { - public class LightFinderUI : WindowMediatorSubscriberBase + #region Services + + private readonly ApiController _apiController; + private readonly DalamudUtilService _dalamudUtilService; + private readonly LightFinderScannerService _broadcastScannerService; + private readonly LightFinderService _broadcastService; + private readonly LightlessConfigService _configService; + private readonly LightlessProfileManager _lightlessProfileManager; + private readonly PairUiService _pairUiService; + private readonly UiSharedService _uiSharedService; + + #endregion + + #region UI Components + + private readonly AnimatedHeader _animatedHeader = new(); + private readonly List _seResolvedSegments = new(); + private readonly Vector4 _tagBackgroundColor = new(0.18f, 0.18f, 0.18f, 0.95f); + private readonly Vector4 _tagBorderColor = new(0.35f, 0.35f, 0.35f, 0.4f); + + #endregion + + #region State + + private IReadOnlyList _allSyncshells = Array.Empty(); + private bool _compactView; + private List _currentSyncshells = []; + private GroupJoinDto? _joinDto; + private GroupJoinInfoDto? _joinInfo; + private readonly List _nearbySyncshells = []; + private DefaultPermissionsDto _ownPermissions = null!; + private readonly HashSet _recentlyJoined = new(StringComparer.Ordinal); + private int _selectedNearbyIndex = -1; + private readonly List<(string Label, string? GID, bool IsAvailable)> _syncshellOptions = new(); + private LightfinderTab _selectedTab = LightfinderTab.NearbySyncshells; + private string _userUid = string.Empty; + + private enum LightfinderTab { NearbySyncshells, BroadcastSettings, Help } + +#if DEBUG + private enum LightfinderTabDebug { NearbySyncshells, BroadcastSettings, Help, Debug } + private LightfinderTabDebug _selectedTabDebug = LightfinderTabDebug.NearbySyncshells; +#endif + +#if DEBUG + private bool _useTestSyncshells; +#endif + + #endregion + + #region Constructor + + public LightFinderUI( + ILogger logger, + LightlessMediator mediator, + PerformanceCollectorService performanceCollectorService, + LightFinderService broadcastService, + LightlessConfigService configService, + UiSharedService uiShared, + ApiController apiController, + LightFinderScannerService broadcastScannerService, + PairUiService pairUiService, + DalamudUtilService dalamudUtilService, + LightlessProfileManager lightlessProfileManager + ) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService) { - private readonly ApiController _apiController; - private readonly LightlessConfigService _configService; - private readonly LightFinderService _broadcastService; - private readonly UiSharedService _uiSharedService; - private readonly LightFinderScannerService _broadcastScannerService; + _broadcastService = broadcastService; + _uiSharedService = uiShared; + _configService = configService; + _apiController = apiController; + _broadcastScannerService = broadcastScannerService; + _pairUiService = pairUiService; + _dalamudUtilService = dalamudUtilService; + _lightlessProfileManager = lightlessProfileManager; - private IReadOnlyList _allSyncshells = Array.Empty(); - private string _userUid = string.Empty; + _animatedHeader.Height = 100f; + _animatedHeader.EnableBottomGradient = true; + _animatedHeader.GradientHeight = 120f; + _animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects; - private readonly List<(string Label, string? GID, bool IsAvailable)> _syncshellOptions = new(); + IsOpen = false; + WindowBuilder.For(this) + .SetSizeConstraints(new Vector2(620, 85), new Vector2(700, 600)) + .Apply(); - public LightFinderUI( - ILogger logger, - LightlessMediator mediator, - PerformanceCollectorService performanceCollectorService, - LightFinderService broadcastService, - LightlessConfigService configService, - UiSharedService uiShared, - ApiController apiController, - LightFinderScannerService broadcastScannerService - ) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService) + Mediator.Subscribe(this, async _ => await RefreshNearbySyncshellsAsync().ConfigureAwait(false)); + Mediator.Subscribe(this, async _ => await RefreshNearbySyncshellsAsync().ConfigureAwait(false)); + Mediator.Subscribe(this, async _ => await RefreshNearbySyncshellsAsync(_.gid).ConfigureAwait(false)); + Mediator.Subscribe(this, async _ => await RefreshNearbySyncshellsAsync(_.gid).ConfigureAwait(false)); + } + + #endregion + + #region Lifecycle + + public override void OnOpen() + { + _userUid = _apiController.UID; + _ownPermissions = _apiController.DefaultPermissions.DeepClone()!; + _ = RefreshSyncshellsAsync(); + _ = RefreshNearbySyncshellsAsync(); + } + + public override void OnClose() + { + _animatedHeader.ClearParticles(); + ClearSelection(); + base.OnClose(); + } + + #endregion + + #region Main Drawing + + protected override void DrawInternal() + { + var contentWidth = ImGui.GetContentRegionAvail().X; + _animatedHeader.Draw(contentWidth, (_, _) => { }); + + if (!_broadcastService.IsLightFinderAvailable) { - _broadcastService = broadcastService; - _uiSharedService = uiShared; - _configService = configService; - _apiController = apiController; - _broadcastScannerService = broadcastScannerService; - - IsOpen = false; - WindowBuilder.For(this) - .SetSizeConstraints(new Vector2(600, 465), new Vector2(750, 525)) - .Apply(); + ImGui.TextColored(UIColors.Get("LightlessYellow"), "This server doesn't support Lightfinder."); + ImGuiHelpers.ScaledDummy(2f); } - private void RebuildSyncshellDropdownOptions() + DrawStatusPanel(); + ImGuiHelpers.ScaledDummy(4f); + +#if DEBUG + var debugTabOptions = new List> { - var selectedGid = _configService.Current.SelectedFinderSyncshell; - var allSyncshells = _allSyncshells ?? []; - var filteredSyncshells = allSyncshells - .Where(g => string.Equals(g.OwnerUID, _userUid, StringComparison.Ordinal) || g.GroupUserInfo.IsModerator()) - .ToList(); + new("Nearby Syncshells", LightfinderTabDebug.NearbySyncshells), + new("Broadcast", LightfinderTabDebug.BroadcastSettings), + new("Help", LightfinderTabDebug.Help), + new("Debug", LightfinderTabDebug.Debug) + }; + UiSharedService.Tab("LightfinderTabs", debugTabOptions, ref _selectedTabDebug); + + ImGuiHelpers.ScaledDummy(4f); + + switch (_selectedTabDebug) + { + case LightfinderTabDebug.NearbySyncshells: + DrawNearbySyncshellsTab(); + break; + case LightfinderTabDebug.BroadcastSettings: + DrawBroadcastSettingsTab(); + break; + case LightfinderTabDebug.Help: + DrawHelpTab(); + break; + case LightfinderTabDebug.Debug: + DrawDebugTab(); + break; + } +#else + var tabOptions = new List> + { + new("Nearby Syncshells", LightfinderTab.NearbySyncshells), + new("Broadcast", LightfinderTab.BroadcastSettings), + new("Help", LightfinderTab.Help) + }; + UiSharedService.Tab("LightfinderTabs", tabOptions, ref _selectedTab); + + ImGuiHelpers.ScaledDummy(4f); + + switch (_selectedTab) + { + case LightfinderTab.NearbySyncshells: + DrawNearbySyncshellsTab(); + break; + case LightfinderTab.BroadcastSettings: + DrawBroadcastSettingsTab(); + break; + case LightfinderTab.Help: + DrawHelpTab(); + break; + } +#endif - _syncshellOptions.Clear(); - _syncshellOptions.Add(("None", null, true)); + if (_joinDto != null && _joinInfo != null && _joinInfo.Success) + DrawJoinConfirmation(); + } - var addedGids = new HashSet(StringComparer.Ordinal); + private void DrawStatusPanel() + { + var scale = ImGuiHelpers.GlobalScale; + var isBroadcasting = _broadcastService.IsBroadcasting; + var cooldown = _broadcastService.RemainingCooldown; + var isOnCooldown = cooldown.HasValue && cooldown.Value.TotalSeconds > 0; - foreach (var shell in filteredSyncshells) + var accent = isBroadcasting ? UIColors.Get("LightlessGreen") : UIColors.Get("LightlessPurple"); + var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.16f); + var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.32f); + var infoColor = ImGuiColors.DalamudGrey; + + var summaryHeight = MathF.Max(ImGui.GetTextLineHeightWithSpacing() * 2.4f, 46f * scale); + float buttonWidth = 130 * scale; + + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize))) + using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(12f * scale, 6f * scale))) + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(18f * scale, 4f * scale))) + using (ImRaii.PushColor(ImGuiCol.ChildBg, UiSharedService.Color(accentBg))) + using (ImRaii.PushColor(ImGuiCol.Border, UiSharedService.Color(accentBorder))) + using (var child = ImRaii.Child("StatusPanel", new Vector2(-1f, summaryHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) + { + if (child) { - var label = shell.GroupAliasOrGID ?? shell.GID; - _syncshellOptions.Add((label, shell.GID, true)); - addedGids.Add(shell.GID); - } - - if (!string.IsNullOrEmpty(selectedGid) && !addedGids.Contains(selectedGid)) - { - var matching = allSyncshells.FirstOrDefault(g => string.Equals(g.GID, selectedGid, StringComparison.Ordinal)); - if (matching != null) + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(8f * scale, 4f * scale))) { - var label = matching.GroupAliasOrGID ?? matching.GID; - _syncshellOptions.Add((label, matching.GID, true)); - addedGids.Add(matching.GID); + if (ImGui.BeginTable("StatusPanelTable", 6, ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.NoBordersInBody)) + { + ImGui.TableSetupColumn("Status", ImGuiTableColumnFlags.WidthStretch, 1f); + ImGui.TableSetupColumn("Time", ImGuiTableColumnFlags.WidthStretch, 1f); + ImGui.TableSetupColumn("NearbyPlayers", ImGuiTableColumnFlags.WidthStretch, 1f); + ImGui.TableSetupColumn("NearbySyncshells", ImGuiTableColumnFlags.WidthStretch, 1f); + ImGui.TableSetupColumn("Broadcasting", ImGuiTableColumnFlags.WidthStretch, 1f); + ImGui.TableSetupColumn("Button", ImGuiTableColumnFlags.WidthFixed, buttonWidth + 16f * scale); + + ImGui.TableNextRow(); + + // Status cell + var statusColor = isBroadcasting ? UIColors.Get("LightlessGreen") : UIColors.Get("DimRed"); + var statusText = isBroadcasting ? "Active" : "Inactive"; + var statusIcon = isBroadcasting ? FontAwesomeIcon.CheckCircle : FontAwesomeIcon.TimesCircle; + DrawStatusCell(statusIcon, statusColor, statusText, "Status", infoColor, scale); + + // Time remaining Cooldown cell + string timeValue; + string timeSub; + Vector4 timeColor; + if (isOnCooldown) + { + timeValue = $"{Math.Ceiling(cooldown!.Value.TotalSeconds)}s"; + timeSub = "Cooldown"; + timeColor = UIColors.Get("DimRed"); + } + else if (isBroadcasting && _broadcastService.RemainingTtl is { } remaining && remaining > TimeSpan.Zero) + { + timeValue = $"{remaining:hh\\:mm\\:ss}"; + timeSub = "Time left"; + timeColor = UIColors.Get("LightlessYellow"); + } + else + { + timeValue = "--:--:--"; + timeSub = "Time left"; + timeColor = infoColor; + } + DrawStatusCell(FontAwesomeIcon.Clock, timeColor, timeValue, timeSub, infoColor, scale); + + // Nearby players cell + var nearbyPlayerCount = _broadcastScannerService.CountActiveBroadcasts(); + var nearbyPlayerColor = nearbyPlayerCount > 0 ? UIColors.Get("LightlessBlue") : infoColor; + DrawStatusCell(FontAwesomeIcon.Users, nearbyPlayerColor, nearbyPlayerCount.ToString(), "Players", infoColor, scale); + + // Nearby syncshells cell + var nearbySyncshellCount = _nearbySyncshells.Count; + var nearbySyncshellColor = nearbySyncshellCount > 0 ? UIColors.Get("LightlessPurple") : infoColor; + DrawStatusCell(FontAwesomeIcon.Compass, nearbySyncshellColor, nearbySyncshellCount.ToString(), "Syncshells", infoColor, scale); + + // Broadcasting syncshell cell + var isBroadcastingSyncshell = _configService.Current.SyncshellFinderEnabled && isBroadcasting; + var broadcastSyncshellColor = isBroadcastingSyncshell ? UIColors.Get("LightlessGreen") : infoColor; + var broadcastSyncshellText = isBroadcastingSyncshell ? "Yes" : "No"; + var broadcastSyncshellIcon = FontAwesomeIcon.Wifi; + DrawStatusCell(broadcastSyncshellIcon, broadcastSyncshellColor, broadcastSyncshellText, "Broadcasting", infoColor, scale); + + // Enable/Disable button cell - right aligned + ImGui.TableNextColumn(); + + float cellWidth = ImGui.GetContentRegionAvail().X; + float offsetX = cellWidth - buttonWidth; + if (offsetX > 0) + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + offsetX); + + using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 6f * scale)) + { + Vector4 buttonColor; + if (isOnCooldown) + buttonColor = UIColors.Get("DimRed"); + else if (isBroadcasting) + buttonColor = UIColors.Get("LightlessGreen"); + else + buttonColor = UIColors.Get("LightlessPurple"); + + using (ImRaii.PushColor(ImGuiCol.Button, buttonColor)) + using (ImRaii.PushColor(ImGuiCol.ButtonHovered, buttonColor.WithAlpha(0.85f))) + using (ImRaii.PushColor(ImGuiCol.ButtonActive, buttonColor.WithAlpha(0.75f))) + using (ImRaii.Disabled(isOnCooldown || !_broadcastService.IsLightFinderAvailable)) + { + string buttonText = isBroadcasting ? "Disable" : "Enable"; + if (ImGui.Button(buttonText, new Vector2(buttonWidth, 0))) + _broadcastService.ToggleBroadcast(); + } + } + + ImGui.EndTable(); + } } } + } + } - if (!string.IsNullOrEmpty(selectedGid) && !addedGids.Contains(selectedGid)) + private void DrawStatusCell(FontAwesomeIcon icon, Vector4 iconColor, string mainText, string subText, Vector4 subColor, float scale) + { + ImGui.TableNextColumn(); + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(6f * scale, 2f * scale))) + using (ImRaii.Group()) + { + _uiSharedService.IconText(icon, iconColor); + ImGui.SameLine(0f, 6f * scale); + using (ImRaii.PushColor(ImGuiCol.Text, iconColor)) { - _syncshellOptions.Add(($"[Unavailable] {selectedGid}", selectedGid, false)); + ImGui.TextUnformatted(mainText); + } + using (ImRaii.PushColor(ImGuiCol.Text, subColor)) + { + ImGui.TextUnformatted(subText); + } + } + } + + #endregion + + #region Nearby Syncshells Tab + + private void DrawNearbySyncshellsTab() + { + ImGui.BeginGroup(); + +#if DEBUG + if (ImGui.SmallButton("Test Data")) + { + _useTestSyncshells = !_useTestSyncshells; + _ = Task.Run(async () => await RefreshNearbySyncshellsAsync().ConfigureAwait(false)); + } + ImGui.SameLine(); +#endif + + string checkboxLabel = "Compact"; + float checkboxWidth = ImGui.CalcTextSize(checkboxLabel).X + ImGui.GetFrameHeight() + 8f; + float availWidth = ImGui.GetContentRegionAvail().X; + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + availWidth - checkboxWidth); + ImGui.Checkbox(checkboxLabel, ref _compactView); + ImGui.EndGroup(); + + ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); + + if (_nearbySyncshells.Count == 0) + { + DrawNoSyncshellsMessage(); + return; + } + + var cardData = BuildSyncshellCardData(); + if (cardData.Count == 0) + { + ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells found."); + return; + } + + if (_compactView) + DrawSyncshellGrid(cardData); + else + DrawSyncshellList(cardData); + } + + private void DrawNoSyncshellsMessage() + { + ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells are being broadcasted."); + + if (!_broadcastService.IsBroadcasting) + { + ImGuiHelpers.ScaledDummy(4f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow")); + ImGuiHelpers.ScaledDummy(2f); + + ImGui.TextColored(UIColors.Get("LightlessYellow"), "Lightfinder must be active to find nearby syncshells."); + } + } + + private List<(GroupJoinDto Shell, string BroadcasterName)> BuildSyncshellCardData() + { + string? myHashedCid = null; + try + { + var cid = _dalamudUtilService.GetCID(); + myHashedCid = cid.ToString().GetHash256(); + } + catch { } + + var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts() + .Where(b => !string.Equals(b.HashedCID, myHashedCid, StringComparison.Ordinal)) + .ToList(); + + var cardData = new List<(GroupJoinDto Shell, string BroadcasterName)>(); + + foreach (var shell in _nearbySyncshells) + { + if (shell?.Group == null || string.IsNullOrEmpty(shell.Group.GID)) + continue; + +#if DEBUG + if (_useTestSyncshells) + { + var displayName = !string.IsNullOrEmpty(shell.Group.Alias) ? shell.Group.Alias : shell.Group.GID; + cardData.Add((shell, $"{displayName} (Test World)")); + continue; + } +#endif + + var broadcast = broadcasts.FirstOrDefault(b => string.Equals(b.GID, shell.Group.GID, StringComparison.Ordinal)); + if (broadcast == null) + continue; + + var (name, address) = _dalamudUtilService.FindPlayerByNameHash(broadcast.HashedCID); + if (string.IsNullOrEmpty(name)) + continue; + + var worldName = _dalamudUtilService.GetWorldNameFromPlayerAddress(address); + var broadcasterName = !string.IsNullOrEmpty(worldName) ? $"{name} ({worldName})" : name; + + cardData.Add((shell, broadcasterName)); + } + + return cardData; + } + + private void DrawSyncshellList(List<(GroupJoinDto Shell, string BroadcasterName)> listData) + { + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8f); + ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1f); + + if (ImGui.BeginChild("SyncshellListScroll", new Vector2(-1, -1), border: false)) + { + foreach (var (shell, broadcasterName) in listData) + { + DrawSyncshellListItem(shell, broadcasterName); + ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); + } + } + ImGui.EndChild(); + + ImGui.PopStyleVar(2); + } + + private void DrawSyncshellListItem(GroupJoinDto shell, string broadcasterName) + { + ImGui.PushID(shell.Group.GID); + float rowHeight = 74f * ImGuiHelpers.GlobalScale; + + ImGui.BeginChild($"ShellRow##{shell.Group.GID}", new Vector2(-1, rowHeight), border: true); + + var displayName = !string.IsNullOrEmpty(shell.Group.Alias) ? shell.Group.Alias : shell.Group.GID; + var style = ImGui.GetStyle(); + float startX = ImGui.GetCursorPosX(); + float regionW = ImGui.GetContentRegionAvail().X; + float rightTxtW = ImGui.CalcTextSize(broadcasterName).X; + + _uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple")); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Click to open profile."); + if (ImGui.IsItemClicked()) + Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group)); + + float rightX = startX + regionW - rightTxtW - style.ItemSpacing.X; + ImGui.SameLine(); + ImGui.SetCursorPosX(rightX); + ImGui.TextUnformatted(broadcasterName); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Broadcaster"); + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); + + var groupProfile = _lightlessProfileManager.GetLightlessGroupProfile(shell.Group); + IReadOnlyList groupTags = groupProfile?.Tags.Count > 0 + ? ProfileTagService.ResolveTags(groupProfile.Tags) + : []; + + var limitedTags = groupTags.Count > 3 ? [.. groupTags.Take(3)] : groupTags; + float tagScale = ImGuiHelpers.GlobalScale * 0.9f; + + Vector2 rowStartLocal = ImGui.GetCursorPos(); + float tagsWidth = 0f; + + if (limitedTags.Count > 0) + (tagsWidth, _) = RenderProfileTagsSingleRow(limitedTags, tagScale); + else + { + ImGui.SetCursorPosX(startX); + ImGui.TextDisabled("No tags"); + ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); + } + + float joinX = rowStartLocal.X + (tagsWidth > 0 ? tagsWidth + style.ItemSpacing.X : 0f); + ImGui.SetCursorPos(new Vector2(joinX, rowStartLocal.Y)); + DrawJoinButton(shell, false); + + ImGui.EndChild(); + ImGui.PopID(); + } + + private void DrawSyncshellGrid(List<(GroupJoinDto Shell, string BroadcasterName)> cardData) + { + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8f); + ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1f); + + foreach (var (shell, _) in cardData) + { + DrawSyncshellCompactItem(shell); + ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); + } + + ImGui.PopStyleVar(2); + } + + private void DrawSyncshellCompactItem(GroupJoinDto shell) + { + ImGui.PushID(shell.Group.GID); + float rowHeight = 36f * ImGuiHelpers.GlobalScale; + + ImGui.BeginChild($"ShellCompact##{shell.Group.GID}", new Vector2(-1, rowHeight), border: true); + + var displayName = !string.IsNullOrEmpty(shell.Group.Alias) ? shell.Group.Alias : shell.Group.GID; + var style = ImGui.GetStyle(); + float availW = ImGui.GetContentRegionAvail().X; + + ImGui.AlignTextToFramePadding(); + _uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple")); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Click to open profile."); + if (ImGui.IsItemClicked()) + Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group)); + + ImGui.SameLine(); + DrawJoinButton(shell, false); + + ImGui.EndChild(); + ImGui.PopID(); + } + + private void DrawJoinButton(GroupJoinDto shell, bool fullWidth) + { + const string visibleLabel = "Join"; + var label = $"{visibleLabel}##{shell.Group.GID}"; + + var isAlreadyMember = _currentSyncshells.Exists(g => string.Equals(g.GID, shell.Group.GID, StringComparison.Ordinal)); + var isRecentlyJoined = _recentlyJoined.Contains(shell.Group.GID); + var isOwnBroadcast = _configService.Current.SyncshellFinderEnabled + && _broadcastService.IsBroadcasting + && string.Equals(_configService.Current.SelectedFinderSyncshell, shell.Group.GID, StringComparison.Ordinal); + + Vector2 buttonSize; + if (fullWidth) + { + buttonSize = new Vector2(-1, 0); + } + else + { + var textSize = ImGui.CalcTextSize(visibleLabel); + var width = textSize.X + ImGui.GetStyle().FramePadding.X * 20f; + buttonSize = new Vector2(width, 30f); + + float availX = ImGui.GetContentRegionAvail().X; + float curX = ImGui.GetCursorPosX(); + ImGui.SetCursorPosX(curX + availX - buttonSize.X); + } + + if (isOwnBroadcast) + { + ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessPurple")); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurple").WithAlpha(0.85f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessPurple").WithAlpha(0.75f)); + + using (ImRaii.Disabled()) + ImGui.Button(label, buttonSize); + + UiSharedService.AttachToolTip("You can't join your own Syncshell, silly! That's like trying to high-five yourself."); + } + else if (!isAlreadyMember && !isRecentlyJoined) + { + ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessGreen")); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreen").WithAlpha(0.75f)); + + if (ImGui.Button(label, buttonSize)) + { + _ = Task.Run(async () => + { + try + { + var info = await _apiController.GroupJoinHashed(new GroupJoinHashedDto( + shell.Group, shell.Password, shell.GroupUserPreferredPermissions + )).ConfigureAwait(false); + + if (info?.Success == true) + { + _joinDto = new GroupJoinDto(shell.Group, shell.Password, shell.GroupUserPreferredPermissions); + _joinInfo = info; + _ownPermissions = _apiController.DefaultPermissions.DeepClone()!; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Join failed for {GID}", shell.Group.GID); + } + }); + } + } + else + { + ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("DimRed")); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("DimRed").WithAlpha(0.85f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("DimRed").WithAlpha(0.75f)); + + using (ImRaii.Disabled()) + ImGui.Button(label, buttonSize); + + UiSharedService.AttachToolTip("Already a member of this Syncshell."); + } + + ImGui.PopStyleColor(3); + } + + + private void DrawJoinConfirmation() + { + if (_joinDto == null || _joinInfo == null) return; + + ImGui.Separator(); + ImGui.TextUnformatted($"Join Syncshell: {_joinDto.Group.AliasOrGID} by {_joinInfo.OwnerAliasOrUID}"); + ImGuiHelpers.ScaledDummy(2f); + ImGui.TextUnformatted("Suggested Permissions:"); + + DrawPermissionRow("Sounds", _joinInfo.GroupPermissions.IsPreferDisableSounds(), _ownPermissions.DisableGroupSounds, v => _ownPermissions.DisableGroupSounds = v); + DrawPermissionRow("Animations", _joinInfo.GroupPermissions.IsPreferDisableAnimations(), _ownPermissions.DisableGroupAnimations, v => _ownPermissions.DisableGroupAnimations = v); + DrawPermissionRow("VFX", _joinInfo.GroupPermissions.IsPreferDisableVFX(), _ownPermissions.DisableGroupVFX, v => _ownPermissions.DisableGroupVFX = v); + + ImGui.NewLine(); + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Plus, $"Finalize and join {_joinDto.Group.AliasOrGID}")) + { + var finalPermissions = GroupUserPreferredPermissions.NoneSet; + finalPermissions.SetDisableSounds(_ownPermissions.DisableGroupSounds); + finalPermissions.SetDisableAnimations(_ownPermissions.DisableGroupAnimations); + finalPermissions.SetDisableVFX(_ownPermissions.DisableGroupVFX); + + _ = _apiController.GroupJoinFinalize(new GroupJoinDto(_joinDto.Group, _joinDto.Password, finalPermissions)); + _recentlyJoined.Add(_joinDto.Group.GID); + + _joinDto = null; + _joinInfo = null; + } + + ImGui.SameLine(); + if (ImGui.Button("Cancel")) + { + _joinDto = null; + _joinInfo = null; + } + } + + private void DrawPermissionRow(string label, bool suggested, bool current, Action apply) + { + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted($"- {label}"); + + ImGui.SameLine(120 * ImGuiHelpers.GlobalScale); + ImGui.Text("Current:"); + ImGui.SameLine(); + _uiSharedService.BooleanToColoredIcon(!current); + + ImGui.SameLine(240 * ImGuiHelpers.GlobalScale); + ImGui.Text("Suggested:"); + ImGui.SameLine(); + _uiSharedService.BooleanToColoredIcon(!suggested); + + ImGui.SameLine(380 * ImGuiHelpers.GlobalScale); + using var id = ImRaii.PushId(label); + if (current != suggested) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowRight, "Apply")) + apply(suggested); + } + + ImGui.NewLine(); + } + + #endregion + + #region Broadcast Settings Tab + + private void DrawBroadcastSettingsTab() + { + _uiSharedService.MediumText("Syncshell Broadcasting", UIColors.Get("PairBlue")); + ImGuiHelpers.ScaledDummy(2f); + + ImGui.PushTextWrapPos(); + ImGui.TextColored(ImGuiColors.DalamudGrey, "Broadcast your Syncshell to nearby Lightfinder users. They can then join directly from the Nearby Syncshells tab."); + ImGui.PopTextWrapPos(); + + ImGuiHelpers.ScaledDummy(4f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); + ImGuiHelpers.ScaledDummy(4f); + + bool isBroadcasting = _broadcastService.IsBroadcasting; + + if (isBroadcasting) + { + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), + new SeStringUtils.RichTextEntry("Settings can only be changed while Lightfinder is disabled.", UIColors.Get("LightlessYellow"))); + ImGuiHelpers.ScaledDummy(4f); + } + + if (isBroadcasting) + ImGui.BeginDisabled(); + + bool shellFinderEnabled = _configService.Current.SyncshellFinderEnabled; + if (ImGui.Checkbox("Enable Syncshell Broadcasting", ref shellFinderEnabled)) + { + _configService.Current.SyncshellFinderEnabled = shellFinderEnabled; + _configService.Save(); + } + UiSharedService.AttachToolTip("When enabled and Lightfinder is active, your selected Syncshell will be visible to nearby users."); + + ImGuiHelpers.ScaledDummy(4f); + + ImGui.Text("Select Syncshell to broadcast:"); + + var selectedGid = _configService.Current.SelectedFinderSyncshell; + var currentOption = _syncshellOptions.FirstOrDefault(o => string.Equals(o.GID, selectedGid, StringComparison.Ordinal)); + var preview = currentOption.Label ?? "Select a Syncshell..."; + + ImGui.SetNextItemWidth(300 * ImGuiHelpers.GlobalScale); + if (ImGui.BeginCombo("##SyncshellDropdown", preview)) + { + foreach (var (label, gid, available) in _syncshellOptions) + { + bool isSelected = string.Equals(gid, selectedGid, StringComparison.Ordinal); + + if (!available) + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); + + if (ImGui.Selectable(label, isSelected)) + { + _configService.Current.SelectedFinderSyncshell = gid; + _configService.Save(); + _ = RefreshSyncshellsAsync(); + } + + if (!available && ImGui.IsItemHovered()) + ImGui.SetTooltip("This Syncshell is not available on the current service."); + + if (!available) + ImGui.PopStyleColor(); + + if (isSelected) + ImGui.SetItemDefaultFocus(); + } + + ImGui.EndCombo(); + } + + if (isBroadcasting) + ImGui.EndDisabled(); + + ImGui.SameLine(); + if (_uiSharedService.IconButton(FontAwesomeIcon.Sync)) + _ = RefreshSyncshellsAsync(); + UiSharedService.AttachToolTip("Refresh Syncshell list"); + + ImGuiHelpers.ScaledDummy(8f); + + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8f); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Cog, "Advanced Settings")) + Mediator.Publish(new OpenLightfinderSettingsMessage()); + ImGui.PopStyleVar(); + UiSharedService.AttachToolTip("Open Lightfinder settings in the Settings window."); + } + + #endregion + + #region Help Tab + + private void DrawHelpTab() + { + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(4, 4)); + + _uiSharedService.MediumText("What is Lightfinder?", UIColors.Get("PairBlue")); + ImGui.PushTextWrapPos(); + ImGui.TextColored(ImGuiColors.DalamudGrey, "Lightfinder lets other Lightless users know you use Lightless. While enabled, you and others can see each other via a nameplate label."); + ImGui.PopTextWrapPos(); + + ImGuiHelpers.ScaledDummy(6f); + + _uiSharedService.MediumText("Pairing", UIColors.Get("PairBlue")); + ImGui.PushTextWrapPos(); + ImGui.TextColored(ImGuiColors.DalamudGrey, "Pairing can be initiated via the right-click context menu on another player. The process requires mutual confirmation from both users."); + ImGui.PopTextWrapPos(); + + ImGuiHelpers.ScaledDummy(2f); + + _uiSharedService.DrawNoteLine("", UIColors.Get("LightlessGreen"), + new SeStringUtils.RichTextEntry("If Lightfinder is "), + new SeStringUtils.RichTextEntry("ENABLED", UIColors.Get("LightlessGreen"), true), + new SeStringUtils.RichTextEntry(", the receiving user will get notified about pair requests.")); + + _uiSharedService.DrawNoteLine("", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("If Lightfinder is "), + new SeStringUtils.RichTextEntry("DISABLED", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry(", pair requests will NOT be visible to the recipient.")); + + ImGuiHelpers.ScaledDummy(6f); + + _uiSharedService.MediumText("Privacy", UIColors.Get("PairBlue")); + + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), + new SeStringUtils.RichTextEntry("Lightfinder is entirely "), + new SeStringUtils.RichTextEntry("opt-in", UIColors.Get("LightlessYellow"), true), + new SeStringUtils.RichTextEntry(" and does not share personal data with other users.")); + + ImGui.PushTextWrapPos(); + ImGui.TextColored(ImGuiColors.DalamudGrey, "All identifying information remains private to the server. Use Lightfinder when you're okay with being visible to other users."); + ImGui.PopTextWrapPos(); + + ImGuiHelpers.ScaledDummy(6f); + + _uiSharedService.MediumText("Syncshell Broadcasting", UIColors.Get("PairBlue")); + ImGui.PushTextWrapPos(); + ImGui.TextColored(ImGuiColors.DalamudGrey, "You can broadcast a Syncshell you own or moderate to nearby Lightfinder users. Configure this in the Broadcast Settings tab."); + ImGui.PopTextWrapPos(); + + ImGui.PopStyleVar(); + } + + #endregion + +#if DEBUG + #region Debug Tab + + private void DrawDebugTab() + { + ImGui.Text("Broadcast Cache"); + + if (ImGui.BeginTable("##BroadcastCacheTable", 4, ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.ScrollY, new Vector2(-1, 200f))) + { + ImGui.TableSetupColumn("CID", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Broadcasting", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Expires In", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Syncshell GID", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableHeadersRow(); + + var now = DateTime.UtcNow; + + foreach (var (cid, entry) in _broadcastScannerService.BroadcastCache) + { + ImGui.TableNextRow(); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(cid.Truncate(12)); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(cid); + + ImGui.TableNextColumn(); + var colorBroadcast = entry.IsBroadcasting ? UIColors.Get("LightlessGreen") : UIColors.Get("DimRed"); + ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, ImGui.GetColorU32(colorBroadcast)); + ImGui.TextUnformatted(entry.IsBroadcasting.ToString()); + + ImGui.TableNextColumn(); + var remaining = entry.ExpiryTime - now; + var colorTtl = remaining <= TimeSpan.Zero ? UIColors.Get("DimRed") + : remaining < TimeSpan.FromSeconds(10) ? UIColors.Get("LightlessYellow") + : (Vector4?)null; + + if (colorTtl != null) + ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, ImGui.GetColorU32(colorTtl.Value)); + + ImGui.TextUnformatted(remaining > TimeSpan.Zero ? remaining.ToString("hh\\:mm\\:ss") : "Expired"); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(entry.GID ?? "-"); + } + + ImGui.EndTable(); + } + } + + #endregion +#endif + + #region Data Refresh + + private async Task RefreshSyncshellsAsync() + { + if (!_apiController.IsConnected) + { + _allSyncshells = []; + RebuildSyncshellDropdownOptions(); + return; + } + + try + { + _allSyncshells = await _apiController.GroupsGetAll().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch Syncshells."); + _allSyncshells = []; + } + + RebuildSyncshellDropdownOptions(); + } + + private void RebuildSyncshellDropdownOptions() + { + var selectedGid = _configService.Current.SelectedFinderSyncshell; + var allSyncshells = _allSyncshells ?? []; + var filteredSyncshells = allSyncshells + .Where(g => string.Equals(g.OwnerUID, _userUid, StringComparison.Ordinal) || g.GroupUserInfo.IsModerator()) + .ToList(); + + _syncshellOptions.Clear(); + _syncshellOptions.Add(("None", null, true)); + + var addedGids = new HashSet(StringComparer.Ordinal); + + foreach (var shell in filteredSyncshells) + { + var label = shell.GroupAliasOrGID ?? shell.GID; + _syncshellOptions.Add((label, shell.GID, true)); + addedGids.Add(shell.GID); + } + + if (!string.IsNullOrEmpty(selectedGid) && !addedGids.Contains(selectedGid)) + { + var matching = allSyncshells.FirstOrDefault(g => string.Equals(g.GID, selectedGid, StringComparison.Ordinal)); + if (matching != null) + { + var label = matching.GroupAliasOrGID ?? matching.GID; + _syncshellOptions.Add((label, matching.GID, true)); + addedGids.Add(matching.GID); } } - public Task RefreshSyncshells() - { - return RefreshSyncshellsInternal(); - } + if (!string.IsNullOrEmpty(selectedGid) && !addedGids.Contains(selectedGid)) + _syncshellOptions.Add(($"[Unavailable] {selectedGid}", selectedGid, false)); + } - private async Task RefreshSyncshellsInternal() + private async Task RefreshNearbySyncshellsAsync(string? gid = null) + { + var syncshellBroadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts(); + var snapshot = _pairUiService.GetSnapshot(); + _currentSyncshells = [.. snapshot.GroupPairs.Keys]; + + _recentlyJoined.RemoveWhere(g => _currentSyncshells.Exists(s => string.Equals(s.GID, g, StringComparison.Ordinal))); + + List? updatedList = []; + +#if DEBUG + if (_useTestSyncshells) { - if (!_apiController.IsConnected) + updatedList = BuildTestSyncshells(); + } + else +#endif + { + if (syncshellBroadcasts.Count == 0) { - _allSyncshells = []; - RebuildSyncshellDropdownOptions(); + ClearSyncshells(); return; } try { - _allSyncshells = await _apiController.GroupsGetAll().ConfigureAwait(false); + var groups = await _apiController.GetBroadcastedGroups(syncshellBroadcasts).ConfigureAwait(false); + updatedList = groups?.DistinctBy(g => g.Group.GID).ToList(); } catch (Exception ex) { - _logger.LogError(ex, "Failed to fetch Syncshells."); - _allSyncshells = []; + _logger.LogError(ex, "Failed to refresh broadcasted syncshells."); + return; } - - RebuildSyncshellDropdownOptions(); } - - public override void OnOpen() + if (updatedList == null || updatedList.Count == 0) { - _userUid = _apiController.UID; - _ = RefreshSyncshells(); + ClearSyncshells(); + return; } - protected override void DrawInternal() + if (gid != null && _recentlyJoined.Contains(gid)) + _recentlyJoined.Clear(); + + var previousGid = GetSelectedGid(); + + _nearbySyncshells.Clear(); + _nearbySyncshells.AddRange(updatedList); + + if (previousGid != null) { - if (!_broadcastService.IsLightFinderAvailable) + var newIndex = _nearbySyncshells.FindIndex(s => string.Equals(s.Group.GID, previousGid, StringComparison.Ordinal)); + if (newIndex >= 0) { - _uiSharedService.MediumText("This server doesn't support Lightfinder.", UIColors.Get("LightlessYellow")); - - ImGuiHelpers.ScaledDummy(0.25f); + _selectedNearbyIndex = newIndex; + return; } + } - if (ImGui.BeginTabBar("##BroadcastTabs")) - { - if (ImGui.BeginTabItem("Lightfinder")) - { - _uiSharedService.MediumText("Lightfinder", UIColors.Get("PairBlue")); - - ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(1, -2)); - - _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessPurple"),"This lets other Lightless users know you use Lightless. While enabled, you and others using Lightfinder can see each other identified as Lightless users."); - - ImGui.Indent(15f); - ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); - ImGui.Text("- This is done using a 'Lightless' label above player nameplates."); - ImGui.PopStyleColor(); - ImGui.Unindent(15f); - - ImGuiHelpers.ScaledDummy(3f); - - _uiSharedService.MediumText("Pairing", UIColors.Get("PairBlue")); - _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessPurple"), "Pairing may be initiated via the right-click context menu on another player." + - " The process requires mutual confirmation: the sender initiates the request, and the recipient completes it by responding with a request in return."); - - _uiSharedService.DrawNoteLine( - "! ", - UIColors.Get("LightlessYellow"), - new SeStringUtils.RichTextEntry("If Lightfinder is "), - new SeStringUtils.RichTextEntry("ENABLED", UIColors.Get("LightlessGreen"), true), - new SeStringUtils.RichTextEntry(" when a pair request is made, the receiving user will get notified about it.")); - - _uiSharedService.DrawNoteLine( - "! ", - UIColors.Get("LightlessYellow"), - new SeStringUtils.RichTextEntry("If Lightfinder is "), - new SeStringUtils.RichTextEntry("DISABLED", UIColors.Get("DimRed"), true), - new SeStringUtils.RichTextEntry(" when a pair request is made, the receiving user will "), - new SeStringUtils.RichTextEntry("NOT", UIColors.Get("DimRed"), true), - new SeStringUtils.RichTextEntry(" get a notification, and the request will not be visible to them in any way.")); - - ImGuiHelpers.ScaledDummy(3f); - - _uiSharedService.MediumText("Privacy", UIColors.Get("PairBlue")); - - _uiSharedService.DrawNoteLine( - "! ", - UIColors.Get("DimRed"), - new SeStringUtils.RichTextEntry("Lightfinder is entirely "), - new SeStringUtils.RichTextEntry("opt-in", UIColors.Get("LightlessYellow"), true), - new SeStringUtils.RichTextEntry(" and does not share any data with other users. All identifying information remains private to the server.")); - - _uiSharedService.DrawNoteLine("! ", UIColors.Get("DimRed"), "Pairing is intended as a mutual agreement between both parties. A pair request will not be visible to the recipient unless Lightfinder is enabled."); - - ImGuiHelpers.ScaledDummy(5f); - - ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); - ImGui.TextWrapped("Use Lightfinder when you're okay with being visible to other users and understand that you are responsible for your own experience."); - ImGui.PopStyleColor(); - - ImGui.PopStyleVar(); - - ImGuiHelpers.ScaledDummy(3f); - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); - - if (_configService.Current.BroadcastEnabled) - { - ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessGreen")); - ImGui.Text("The Lightfinder calls, and somewhere, a soul may answer."); // cringe.. - ImGui.PopStyleColor(); - - var ttl = _broadcastService.RemainingTtl; - if (ttl is { } remaining && remaining > TimeSpan.Zero) - { - ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessYellow")); - ImGui.Text($"Still shining, for {remaining:hh\\:mm\\:ss}"); - ImGui.PopStyleColor(); - } - else - { - ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); - ImGui.Text("The Lightfinder’s light wanes, but not in vain."); // cringe.. - ImGui.PopStyleColor(); - } - } - else - { - ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); - ImGui.Text("The Lightfinder rests, waiting to shine again."); // cringe.. - ImGui.PopStyleColor(); - } - - var cooldown = _broadcastService.RemainingCooldown; - if (cooldown is { } cd) - { - ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); - ImGui.Text($"The Lightfinder gathers its strength... ({Math.Ceiling(cd.TotalSeconds)}s)"); - ImGui.PopStyleColor(); - } - - ImGuiHelpers.ScaledDummy(0.5f); - - bool isBroadcasting = _broadcastService.IsBroadcasting; - bool isOnCooldown = cooldown.HasValue && cooldown.Value.TotalSeconds > 0; - - ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 10.0f); - - if (isOnCooldown) - ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("DimRed")); - else if (isBroadcasting) - ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessGreen")); - else - ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("PairBlue")); - - if (isOnCooldown || !_broadcastService.IsLightFinderAvailable) - ImGui.BeginDisabled(); - - string buttonText = isBroadcasting ? "Disable Lightfinder" : "Enable Lightfinder"; - - if (ImGui.Button(buttonText, new Vector2(200 * ImGuiHelpers.GlobalScale, 0))) - { - _broadcastService.ToggleBroadcast(); - } - - var toggleButtonHeight = ImGui.GetItemRectSize().Y; - - if (isOnCooldown || !_broadcastService.IsLightFinderAvailable) - ImGui.EndDisabled(); - - ImGui.PopStyleColor(); - ImGui.PopStyleVar(); - - ImGui.SameLine(); - if (_uiSharedService.IconButton(FontAwesomeIcon.Cog, toggleButtonHeight)) - { - Mediator.Publish(new OpenLightfinderSettingsMessage()); - } - - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.TextUnformatted("Open Lightfinder settings."); - ImGui.EndTooltip(); - } - - ImGui.EndTabItem(); - } - - if (ImGui.BeginTabItem("Syncshell Finder")) - { - if (_allSyncshells == null) - { - ImGui.Text("Loading Syncshells..."); - _ = RefreshSyncshells(); - return; - } - - _uiSharedService.MediumText("Syncshell Finder", UIColors.Get("PairBlue")); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); - - ImGui.PushTextWrapPos(); - ImGui.Text("Allow your owned Syncshell to be indexed by the Nearby Syncshell Finder."); - ImGui.Text("To enable this, select one of your owned Syncshells from the dropdown menu below and ensure that \"Toggle Syncshell Finder\" is enabled. Your Syncshell will be visible in the Nearby Syncshell Finder as long as Lightfinder is active."); - ImGui.PopTextWrapPos(); - - ImGuiHelpers.ScaledDummy(0.2f); - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); - - bool ShellFinderEnabled = _configService.Current.SyncshellFinderEnabled; - bool isBroadcasting = _broadcastService.IsBroadcasting; - - if (isBroadcasting) - { - var warningColor = UIColors.Get("LightlessYellow"); - _uiSharedService.DrawNoteLine("! ", warningColor, - new SeStringUtils.RichTextEntry("Syncshell Finder can only be changed while Lightfinder is disabled.", warningColor)); - ImGuiHelpers.ScaledDummy(0.2f); - } - - if (isBroadcasting) - ImGui.BeginDisabled(); - - if (ImGui.Checkbox("Toggle Syncshell Finder", ref ShellFinderEnabled)) - { - _configService.Current.SyncshellFinderEnabled = ShellFinderEnabled; - _configService.Save(); - } - - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.Text("Toggle to broadcast specified Syncshell."); - ImGui.EndTooltip(); - } - - var selectedGid = _configService.Current.SelectedFinderSyncshell; - var currentOption = _syncshellOptions.FirstOrDefault(o => string.Equals(o.GID, selectedGid, StringComparison.Ordinal)); - var preview = currentOption.Label ?? "Select a Syncshell..."; - - if (ImGui.BeginCombo("##SyncshellDropdown", preview)) - { - foreach (var (label, gid, available) in _syncshellOptions) - { - bool isSelected = string.Equals(gid, selectedGid, StringComparison.Ordinal); - - if (!available) - ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); - - if (ImGui.Selectable(label, isSelected)) - { - _configService.Current.SelectedFinderSyncshell = gid; - _configService.Save(); - _ = RefreshSyncshells(); - } - - if (!available && ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.Text("This Syncshell is not available on the current service."); - ImGui.EndTooltip(); - } - - if (!available) - ImGui.PopStyleColor(); - - if (isSelected) - ImGui.SetItemDefaultFocus(); - } - - ImGui.EndCombo(); - } - - - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.Text("Choose one of the available options."); - ImGui.EndTooltip(); - } - - - if (isBroadcasting) - ImGui.EndDisabled(); - - ImGui.EndTabItem(); - } + ClearSelection(); + } #if DEBUG - if (ImGui.BeginTabItem("Debug")) - { - ImGui.Text("Broadcast Cache"); - - if (ImGui.BeginTable("##BroadcastCacheTable", 4, ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.ScrollY, new Vector2(-1, 225f))) - { - ImGui.TableSetupColumn("CID", ImGuiTableColumnFlags.WidthStretch); - ImGui.TableSetupColumn("IsBroadcasting", ImGuiTableColumnFlags.WidthStretch); - ImGui.TableSetupColumn("Expires In", ImGuiTableColumnFlags.WidthStretch); - ImGui.TableSetupColumn("Syncshell GID", ImGuiTableColumnFlags.WidthStretch); - ImGui.TableHeadersRow(); - - var now = DateTime.UtcNow; - - foreach (var (cid, entry) in _broadcastScannerService.BroadcastCache) - { - ImGui.TableNextRow(); - - ImGui.TableNextColumn(); - ImGui.TextUnformatted(cid.Truncate(12)); - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.TextUnformatted(cid); - ImGui.EndTooltip(); - } - - ImGui.TableNextColumn(); - var colorBroadcast = entry.IsBroadcasting - ? UIColors.Get("LightlessGreen") - : UIColors.Get("DimRed"); - - ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, ImGui.GetColorU32(colorBroadcast)); - ImGui.TextUnformatted(entry.IsBroadcasting.ToString()); - - ImGui.TableNextColumn(); - var remaining = entry.ExpiryTime - now; - var colorTtl = - remaining <= TimeSpan.Zero ? UIColors.Get("DimRed") : - remaining < TimeSpan.FromSeconds(10) ? UIColors.Get("LightlessYellow") : - (Vector4?)null; - - if (colorTtl != null) - ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, ImGui.GetColorU32(colorTtl.Value)); - - ImGui.TextUnformatted(remaining > TimeSpan.Zero - ? remaining.ToString("hh\\:mm\\:ss") - : "Expired"); - - ImGui.TableNextColumn(); - ImGui.TextUnformatted(entry.GID ?? "-"); - } - - ImGui.EndTable(); - } - - ImGui.EndTabItem(); - } + private static List BuildTestSyncshells() + { + return + [ + new(new GroupData("TEST-ALPHA", "Alpha Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-BETA", "Beta Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-GAMMA", "Gamma Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-DELTA", "Delta Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-EPSILON", "Epsilon Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-ZETA", "Zeta Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-ETA", "Eta Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-THETA", "Theta Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-IOTA", "Iota Shell"), "", GroupUserPreferredPermissions.NoneSet), + new(new GroupData("TEST-KAPPA", "Kappa Shell"), "", GroupUserPreferredPermissions.NoneSet), + ]; + } #endif - ImGui.EndTabBar(); + private void ClearSyncshells() + { + if (_nearbySyncshells.Count == 0) return; + _nearbySyncshells.Clear(); + ClearSelection(); + } + + private void ClearSelection() + { + _selectedNearbyIndex = -1; + _joinDto = null; + _joinInfo = null; + } + + private string? GetSelectedGid() + { + if (_selectedNearbyIndex < 0 || _selectedNearbyIndex >= _nearbySyncshells.Count) + return null; + return _nearbySyncshells[_selectedNearbyIndex].Group.GID; + } + + #endregion + + #region Helpers + + private (float widthUsed, float rowHeight) RenderProfileTagsSingleRow(IReadOnlyList tags, float scale) + { + if (tags == null || tags.Count == 0) + return (0f, 0f); + + var drawList = ImGui.GetWindowDrawList(); + var style = ImGui.GetStyle(); + var defaultTextColorU32 = ImGui.GetColorU32(ImGuiCol.Text); + + var baseLocal = ImGui.GetCursorPos(); + var baseScreen = ImGui.GetCursorScreenPos(); + float availableWidth = ImGui.GetContentRegionAvail().X; + if (availableWidth <= 0f) + availableWidth = 1f; + + float cursorLocalX = baseLocal.X; + float cursorScreenX = baseScreen.X; + float rowHeight = 0f; + + for (int i = 0; i < tags.Count; i++) + { + var tag = tags[i]; + if (!tag.HasContent) + continue; + + var tagSize = ProfileTagRenderer.MeasureTag(tag, scale, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _seResolvedSegments, GetIconWrap, _logger); + + float tagWidth = tagSize.X; + float tagHeight = tagSize.Y; + + if (cursorLocalX > baseLocal.X && cursorLocalX + tagWidth > baseLocal.X + availableWidth) + break; + + var tagScreenPos = new Vector2(cursorScreenX, baseScreen.Y); + ImGui.SetCursorScreenPos(tagScreenPos); + ImGui.InvisibleButton($"##profileTagInline_{i}", tagSize); + + ProfileTagRenderer.RenderTag(tag, tagScreenPos, scale, drawList, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _seResolvedSegments, GetIconWrap, _logger); + + cursorLocalX += tagWidth + style.ItemSpacing.X; + cursorScreenX += tagWidth + style.ItemSpacing.X; + rowHeight = MathF.Max(rowHeight, tagHeight); + } + + ImGui.SetCursorPos(new Vector2(baseLocal.X, baseLocal.Y + rowHeight)); + + float widthUsed = cursorLocalX - baseLocal.X; + return (widthUsed, rowHeight); + } + + private static string TruncateTextToWidth(string text, float maxWidth) + { + if (string.IsNullOrEmpty(text)) + return text; + + const string ellipsis = "..."; + float ellipsisWidth = ImGui.CalcTextSize(ellipsis).X; + + if (maxWidth <= ellipsisWidth) + return ellipsis; + + int low = 0; + int high = text.Length; + string best = ellipsis; + + while (low <= high) + { + int mid = (low + high) / 2; + string candidate = string.Concat(text.AsSpan(0, mid), ellipsis); + float width = ImGui.CalcTextSize(candidate).X; + + if (width <= maxWidth) + { + best = candidate; + low = mid + 1; + } + else + { + high = mid - 1; } } + + return best; } + + private IDalamudTextureWrap? GetIconWrap(uint iconId) + { + try + { + if (_uiSharedService.TryGetIcon(iconId, out var wrap) && wrap != null) + return wrap; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to resolve icon {IconId}", iconId); + } + + return null; + } + + #endregion } diff --git a/LightlessSync/UI/SyncshellFinderUI.cs b/LightlessSync/UI/SyncshellFinderUI.cs deleted file mode 100644 index 0586c06..0000000 --- a/LightlessSync/UI/SyncshellFinderUI.cs +++ /dev/null @@ -1,850 +0,0 @@ -using Dalamud.Bindings.ImGui; -using Dalamud.Interface; -using Dalamud.Interface.Colors; -using Dalamud.Interface.Textures.TextureWraps; -using Dalamud.Interface.Utility; -using Dalamud.Interface.Utility.Raii; -using Dalamud.Plugin.Services; -using LightlessSync.API.Data; -using LightlessSync.API.Data.Enum; -using LightlessSync.API.Data.Extensions; -using LightlessSync.API.Dto; -using LightlessSync.API.Dto.Group; -using LightlessSync.Services; -using LightlessSync.Services.LightFinder; -using LightlessSync.Services.Mediator; -using LightlessSync.UI.Services; -using LightlessSync.UI.Tags; -using LightlessSync.Utils; -using LightlessSync.WebAPI; -using Microsoft.Extensions.Logging; -using System.Numerics; - -namespace LightlessSync.UI; - -public class SyncshellFinderUI : WindowMediatorSubscriberBase -{ - private readonly ApiController _apiController; - private readonly LightFinderService _broadcastService; - private readonly UiSharedService _uiSharedService; - private readonly LightFinderScannerService _broadcastScannerService; - private readonly PairUiService _pairUiService; - private readonly DalamudUtilService _dalamudUtilService; - - private Vector4 _tagBackgroundColor = new(0.18f, 0.18f, 0.18f, 0.95f); - private Vector4 _tagBorderColor = new(0.35f, 0.35f, 0.35f, 0.4f); - - private readonly List _seResolvedSegments = new(); - private readonly List _nearbySyncshells = []; - private List _currentSyncshells = []; - private int _selectedNearbyIndex = -1; - private int _syncshellPageIndex = 0; - private readonly HashSet _recentlyJoined = new(StringComparer.Ordinal); - - private GroupJoinDto? _joinDto; - private GroupJoinInfoDto? _joinInfo; - private DefaultPermissionsDto _ownPermissions = null!; - private bool _useTestSyncshells = false; - - private bool _compactView = false; - private readonly LightlessProfileManager _lightlessProfileManager; - - public SyncshellFinderUI( - ILogger logger, - LightlessMediator mediator, - PerformanceCollectorService performanceCollectorService, - LightFinderService broadcastService, - UiSharedService uiShared, - ApiController apiController, - LightFinderScannerService broadcastScannerService, - PairUiService pairUiService, - DalamudUtilService dalamudUtilService, - LightlessProfileManager lightlessProfileManager) : base(logger, mediator, "Shellfinder###LightlessSyncshellFinderUI", performanceCollectorService) - { - _broadcastService = broadcastService; - _uiSharedService = uiShared; - _apiController = apiController; - _broadcastScannerService = broadcastScannerService; - _pairUiService = pairUiService; - _dalamudUtilService = dalamudUtilService; - _lightlessProfileManager = lightlessProfileManager; - - IsOpen = false; - WindowBuilder.For(this) - .SetSizeConstraints(new Vector2(600, 400), new Vector2(600, 550)) - .Apply(); - - Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync().ConfigureAwait(false)); - Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync().ConfigureAwait(false)); - Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync(_.gid).ConfigureAwait(false)); - Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync(_.gid).ConfigureAwait(false)); - } - - public override async void OnOpen() - { - _ownPermissions = _apiController.DefaultPermissions.DeepClone()!; - await RefreshSyncshellsAsync().ConfigureAwait(false); - } - - protected override void DrawInternal() - { - ImGui.BeginGroup(); - _uiSharedService.MediumText("Nearby Syncshells", UIColors.Get("LightlessPurple")); - -#if DEBUG - if (ImGui.SmallButton("Show test syncshells")) - { - _useTestSyncshells = !_useTestSyncshells; - _ = Task.Run(async () => await RefreshSyncshellsAsync().ConfigureAwait(false)); - } - ImGui.SameLine(); -#endif - - string checkboxLabel = "Compact view"; - float availWidth = ImGui.GetContentRegionAvail().X; - float checkboxWidth = ImGui.CalcTextSize(checkboxLabel).X + ImGui.GetFrameHeight(); - - float rightX = ImGui.GetCursorPosX() + availWidth - checkboxWidth - 4.0f; - ImGui.SetCursorPosX(rightX); - ImGui.Checkbox(checkboxLabel, ref _compactView); - ImGui.EndGroup(); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); - ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); - if (_nearbySyncshells.Count == 0) - { - ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells are being broadcasted."); - - if (!_broadcastService.IsBroadcasting) - { - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow")); - - ImGui.TextColored(UIColors.Get("LightlessYellow"), "Lightfinder is currently disabled, to locate nearby syncshells, Lightfinder must be active."); - ImGuiHelpers.ScaledDummy(0.5f); - - ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 10.0f); - ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessPurple")); - - if (ImGui.Button("Open Lightfinder", new Vector2(200 * ImGuiHelpers.GlobalScale, 0))) - { - Mediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); - } - - ImGui.PopStyleColor(); - ImGui.PopStyleVar(); - - return; - } - - return; - } - - string? myHashedCid = null; - try - { - var cid = _dalamudUtilService.GetCID(); - myHashedCid = cid.ToString().GetHash256(); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to get CID, not excluding own broadcast."); - } - var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts().Where(b => !string.Equals(b.HashedCID, myHashedCid, StringComparison.Ordinal)).ToList() ?? []; - - var cardData = new List<(GroupJoinDto Shell, string BroadcasterName)>(); - - foreach (var shell in _nearbySyncshells) - { - string broadcasterName; - - if (shell?.Group == null || string.IsNullOrEmpty(shell.Group.GID)) - continue; - - if (_useTestSyncshells) - { - var displayName = !string.IsNullOrEmpty(shell.Group.Alias) - ? shell.Group.Alias - : shell.Group.GID; - - broadcasterName = $"{displayName} (Tester of TestWorld)"; - } - else - { - var broadcast = broadcasts - .FirstOrDefault(b => string.Equals(b.GID, shell.Group.GID, StringComparison.Ordinal)); - - if (broadcast == null) - continue; - - var (name, address) = _dalamudUtilService.FindPlayerByNameHash(broadcast.HashedCID); - if (string.IsNullOrEmpty(name)) - continue; - - var worldName = _dalamudUtilService.GetWorldNameFromPlayerAddress(address); - broadcasterName = !string.IsNullOrEmpty(worldName) - ? $"{name} ({worldName})" - : name; - } - - cardData.Add((shell, broadcasterName)); - } - - if (cardData.Count == 0) - { - ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells are being broadcasted."); - return; - } - - if (_compactView) - { - DrawSyncshellGrid(cardData); - } - else - { - DrawSyncshellList(cardData); - } - - - if (_joinDto != null && _joinInfo != null && _joinInfo.Success) - DrawConfirmation(); - } - - private void DrawSyncshellList(List<(GroupJoinDto Shell, string BroadcasterName)> listData) - { - const int shellsPerPage = 3; - var totalPages = (int)Math.Ceiling(listData.Count / (float)shellsPerPage); - if (totalPages <= 0) - totalPages = 1; - - _syncshellPageIndex = Math.Clamp(_syncshellPageIndex, 0, totalPages - 1); - - var firstIndex = _syncshellPageIndex * shellsPerPage; - var lastExclusive = Math.Min(firstIndex + shellsPerPage, listData.Count); - - ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8.0f); - ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1.0f); - - for (int index = firstIndex; index < lastExclusive; index++) - { - var (shell, broadcasterName) = listData[index]; - - ImGui.PushID(shell.Group.GID); - float rowHeight = 74f * ImGuiHelpers.GlobalScale; - - ImGui.BeginChild($"ShellRow##{shell.Group.GID}", new Vector2(-1, rowHeight), border: true); - - var displayName = !string.IsNullOrEmpty(shell.Group.Alias) ? shell.Group.Alias : shell.Group.GID; - - var style = ImGui.GetStyle(); - float startX = ImGui.GetCursorPosX(); - float regionW = ImGui.GetContentRegionAvail().X; - float rightTxtW = ImGui.CalcTextSize(broadcasterName).X; - - _uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple")); - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Click to open profile."); - if (ImGui.IsItemClicked()) - { - Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group)); - } - - float rightX = startX + regionW - rightTxtW - style.ItemSpacing.X; - ImGui.SameLine(); - ImGui.SetCursorPosX(rightX); - ImGui.TextUnformatted(broadcasterName); - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Broadcaster of the syncshell."); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); - - var groupProfile = _lightlessProfileManager.GetLightlessGroupProfile(shell.Group); - - IReadOnlyList groupTags = - groupProfile != null && groupProfile.Tags.Count > 0 - ? ProfileTagService.ResolveTags(groupProfile.Tags) - : []; - - var limitedTags = groupTags.Count > 3 - ? [.. groupTags.Take(3)] - : groupTags; - - float tagScale = ImGuiHelpers.GlobalScale * 0.9f; - - Vector2 rowStartLocal = ImGui.GetCursorPos(); - - float tagsWidth = 0f; - float tagsHeight = 0f; - - if (limitedTags.Count > 0) - { - (tagsWidth, tagsHeight) = RenderProfileTagsSingleRow(limitedTags, tagScale); - } - else - { - ImGui.SetCursorPosX(startX); - ImGui.TextDisabled("-- No tags set --"); - ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); - } - - float btnBaselineY = rowStartLocal.Y; - float joinX = rowStartLocal.X + (tagsWidth > 0 ? tagsWidth + style.ItemSpacing.X : 0f); - - ImGui.SetCursorPos(new Vector2(joinX, btnBaselineY)); - DrawJoinButton(shell); - - float btnHeight = ImGui.GetFrameHeightWithSpacing(); - float rowHeightUsed = MathF.Max(tagsHeight, btnHeight); - - ImGui.SetCursorPos(new Vector2( - rowStartLocal.X, - rowStartLocal.Y + rowHeightUsed)); - - ImGui.EndChild(); - ImGui.PopID(); - - ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); - } - - ImGui.PopStyleVar(2); - - DrawPagination(totalPages); - } - - private void DrawSyncshellGrid(List<(GroupJoinDto Shell, string BroadcasterName)> cardData) - { - const int shellsPerPage = 4; - var totalPages = (int)Math.Ceiling(cardData.Count / (float)shellsPerPage); - if (totalPages <= 0) - totalPages = 1; - - _syncshellPageIndex = Math.Clamp(_syncshellPageIndex, 0, totalPages - 1); - - var firstIndex = _syncshellPageIndex * shellsPerPage; - var lastExclusive = Math.Min(firstIndex + shellsPerPage, cardData.Count); - - var avail = ImGui.GetContentRegionAvail(); - var spacing = ImGui.GetStyle().ItemSpacing; - - var cardWidth = (avail.X - spacing.X) / 2.0f; - var cardHeight = (avail.Y - spacing.Y - (ImGui.GetFrameHeightWithSpacing() * 2.0f)) / 2.0f; - cardHeight = MathF.Max(110f * ImGuiHelpers.GlobalScale, cardHeight); - - ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8.0f); - ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1.0f); - - for (int index = firstIndex; index < lastExclusive; index++) - { - var localIndex = index - firstIndex; - var (shell, broadcasterName) = cardData[index]; - - if (localIndex % 2 != 0) - ImGui.SameLine(); - - ImGui.PushID(shell.Group.GID); - - ImGui.BeginGroup(); - _ = ImGui.BeginChild("ShellCard##" + shell.Group.GID, new Vector2(cardWidth, cardHeight), border: true); - - var displayName = !string.IsNullOrEmpty(shell.Group.Alias) - ? shell.Group.Alias - : shell.Group.GID; - - var style = ImGui.GetStyle(); - float startX = ImGui.GetCursorPosX(); - float availW = ImGui.GetContentRegionAvail().X; - - ImGui.BeginGroup(); - - _uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple")); - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Click to open profile."); - if (ImGui.IsItemClicked()) - { - Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group)); - } - - float nameRightX = ImGui.GetItemRectMax().X; - - var regionMinScreen = ImGui.GetCursorScreenPos(); - float regionRightX = regionMinScreen.X + availW; - - float minBroadcasterX = nameRightX + style.ItemSpacing.X; - - float maxBroadcasterWidth = regionRightX - minBroadcasterX; - - string broadcasterToShow = broadcasterName; - - if (!string.IsNullOrEmpty(broadcasterName) && maxBroadcasterWidth > 0f) - { - float bcFullWidth = ImGui.CalcTextSize(broadcasterName).X; - string toolTip; - - if (bcFullWidth > maxBroadcasterWidth) - { - broadcasterToShow = TruncateTextToWidth(broadcasterName, maxBroadcasterWidth); - toolTip = broadcasterName + Environment.NewLine + Environment.NewLine + "Broadcaster of the syncshell."; - } - else - { - toolTip = "Broadcaster of the syncshell."; - } - - float bcWidth = ImGui.CalcTextSize(broadcasterToShow).X; - - float broadX = regionRightX - bcWidth; - - broadX = MathF.Max(broadX, minBroadcasterX); - - ImGui.SameLine(); - var curPos = ImGui.GetCursorPos(); - ImGui.SetCursorPos(new Vector2(broadX - regionMinScreen.X + startX, curPos.Y + 3f * ImGuiHelpers.GlobalScale)); - ImGui.TextUnformatted(broadcasterToShow); - if (ImGui.IsItemHovered()) - ImGui.SetTooltip(toolTip); - } - - ImGui.EndGroup(); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); - - ImGui.Dummy(new Vector2(0, 6 * ImGuiHelpers.GlobalScale)); - - var groupProfile = _lightlessProfileManager.GetLightlessGroupProfile(shell.Group); - - IReadOnlyList groupTags = - groupProfile != null && groupProfile.Tags.Count > 0 - ? ProfileTagService.ResolveTags(groupProfile.Tags) - : []; - - float tagScale = ImGuiHelpers.GlobalScale * 0.9f; - - if (groupTags.Count > 0) - { - var limitedTags = groupTags.Count > 2 - ? [.. groupTags.Take(2)] - : groupTags; - - ImGui.SetCursorPosX(startX); - - var (_, tagsHeight) = RenderProfileTagsSingleRow(limitedTags, tagScale); - - ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); - } - else - { - ImGui.SetCursorPosX(startX); - ImGui.TextDisabled("-- No tags set --"); - ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); - } - - var buttonHeight = ImGui.GetFrameHeightWithSpacing(); - var remainingY = ImGui.GetContentRegionAvail().Y - buttonHeight; - if (remainingY > 0) - ImGui.Dummy(new Vector2(0, remainingY)); - - DrawJoinButton(shell); - - ImGui.EndChild(); - ImGui.EndGroup(); - - ImGui.PopID(); - } - - ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); - ImGui.PopStyleVar(2); - - DrawPagination(totalPages); - } - - private void DrawPagination(int totalPages) - { - if (totalPages > 1) - { - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); - - var style = ImGui.GetStyle(); - string pageLabel = $"Page {_syncshellPageIndex + 1}/{totalPages}"; - - float prevWidth = ImGui.CalcTextSize("<").X + style.FramePadding.X * 2; - float nextWidth = ImGui.CalcTextSize(">").X + style.FramePadding.X * 2; - float textWidth = ImGui.CalcTextSize(pageLabel).X; - - float totalWidth = prevWidth + textWidth + nextWidth + style.ItemSpacing.X * 2; - - float availWidth = ImGui.GetContentRegionAvail().X; - float offsetX = (availWidth - totalWidth) * 0.5f; - - ImGui.SetCursorPosX(ImGui.GetCursorPosX() + offsetX); - - if (ImGui.Button("<##PrevSyncshellPage") && _syncshellPageIndex > 0) - _syncshellPageIndex--; - - ImGui.SameLine(); - ImGui.Text(pageLabel); - - ImGui.SameLine(); - if (ImGui.Button(">##NextSyncshellPage") && _syncshellPageIndex < totalPages - 1) - _syncshellPageIndex++; - } - } - - private void DrawJoinButton(dynamic shell) - { - const string visibleLabel = "Join"; - var label = $"{visibleLabel}##{shell.Group.GID}"; - - var isAlreadyMember = _currentSyncshells.Exists(g => string.Equals(g.GID, shell.GID, StringComparison.Ordinal)); - var isRecentlyJoined = _recentlyJoined.Contains(shell.GID); - - Vector2 buttonSize; - - if (!_compactView) - { - var style = ImGui.GetStyle(); - var textSize = ImGui.CalcTextSize(visibleLabel); - - var width = textSize.X + style.FramePadding.X * 20f; - buttonSize = new Vector2(width, 30f); - - float availX = ImGui.GetContentRegionAvail().X; - float curX = ImGui.GetCursorPosX(); - float newX = curX + (availX - buttonSize.X); - ImGui.SetCursorPosX(newX); - } - else - { - buttonSize = new Vector2(-1, 0); - } - - if (!isAlreadyMember && !isRecentlyJoined) - { - ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessGreen")); - ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f)); - ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreen").WithAlpha(0.75f)); - if (ImGui.Button(label, buttonSize)) - { - _logger.LogInformation($"Join requested for Syncshell {shell.Group.GID} ({shell.Group.Alias})"); - - _ = Task.Run(async () => - { - try - { - var info = await _apiController.GroupJoinHashed(new GroupJoinHashedDto( - shell.Group, - shell.Password, - shell.GroupUserPreferredPermissions - )).ConfigureAwait(false); - - if (info != null && info.Success) - { - _joinDto = new GroupJoinDto(shell.Group, shell.Password, shell.GroupUserPreferredPermissions); - _joinInfo = info; - _ownPermissions = _apiController.DefaultPermissions.DeepClone()!; - - _logger.LogInformation($"Fetched join info for {shell.Group.GID}"); - } - else - { - _logger.LogWarning($"Failed to join {shell.Group.GID}: info was null or unsuccessful"); - } - } - catch (Exception ex) - { - _logger.LogError(ex, $"Join failed for {shell.Group.GID}"); - } - }); - } - } - else - { - ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("DimRed")); - ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("DimRed").WithAlpha(0.85f)); - ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("DimRed").WithAlpha(0.75f)); - - using (ImRaii.Disabled()) - { - ImGui.Button(label, buttonSize); - } - - UiSharedService.AttachToolTip("Already a member or owner of this Syncshell."); - } - - ImGui.PopStyleColor(3); - } - - private (float widthUsed, float rowHeight) RenderProfileTagsSingleRow(IReadOnlyList tags, float scale) - { - if (tags == null || tags.Count == 0) - return (0f, 0f); - - var drawList = ImGui.GetWindowDrawList(); - var style = ImGui.GetStyle(); - var defaultTextColorU32 = ImGui.GetColorU32(ImGuiCol.Text); - - var baseLocal = ImGui.GetCursorPos(); - var baseScreen = ImGui.GetCursorScreenPos(); - float availableWidth = ImGui.GetContentRegionAvail().X; - if (availableWidth <= 0f) - availableWidth = 1f; - - float cursorLocalX = baseLocal.X; - float cursorScreenX = baseScreen.X; - float rowHeight = 0f; - - for (int i = 0; i < tags.Count; i++) - { - var tag = tags[i]; - if (!tag.HasContent) - continue; - - var tagSize = ProfileTagRenderer.MeasureTag(tag, scale, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _seResolvedSegments, GetIconWrap, _logger); - - float tagWidth = tagSize.X; - float tagHeight = tagSize.Y; - - if (cursorLocalX > baseLocal.X && cursorLocalX + tagWidth > baseLocal.X + availableWidth) - break; - - var tagScreenPos = new Vector2(cursorScreenX, baseScreen.Y); - ImGui.SetCursorScreenPos(tagScreenPos); - ImGui.InvisibleButton($"##profileTagInline_{i}", tagSize); - - ProfileTagRenderer.RenderTag(tag, tagScreenPos, scale, drawList, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _seResolvedSegments, GetIconWrap, _logger); - - cursorLocalX += tagWidth + style.ItemSpacing.X; - cursorScreenX += tagWidth + style.ItemSpacing.X; - rowHeight = MathF.Max(rowHeight, tagHeight); - } - - ImGui.SetCursorPos(new Vector2(baseLocal.X, baseLocal.Y + rowHeight)); - - float widthUsed = cursorLocalX - baseLocal.X; - return (widthUsed, rowHeight); - } - private static string TruncateTextToWidth(string text, float maxWidth) - { - if (string.IsNullOrEmpty(text)) - return text; - - const string ellipsis = "..."; - float ellipsisWidth = ImGui.CalcTextSize(ellipsis).X; - - if (maxWidth <= ellipsisWidth) - return ellipsis; - - int low = 0; - int high = text.Length; - string best = ellipsis; - - while (low <= high) - { - int mid = (low + high) / 2; - string candidate = string.Concat(text.AsSpan(0, mid), ellipsis); - float width = ImGui.CalcTextSize(candidate).X; - - if (width <= maxWidth) - { - best = candidate; - low = mid + 1; - } - else - { - high = mid - 1; - } - } - - return best; - } - - private IDalamudTextureWrap? GetIconWrap(uint iconId) - { - try - { - if (_uiSharedService.TryGetIcon(iconId, out var wrap) && wrap != null) - return wrap; - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to resolve icon {IconId} for profile tags", iconId); - } - - return null; - } - - private void DrawConfirmation() - { - if (_joinDto != null && _joinInfo != null) - { - ImGui.Separator(); - ImGui.TextUnformatted($"Join Syncshell: {_joinDto.Group.AliasOrGID} by {_joinInfo.OwnerAliasOrUID}"); - ImGuiHelpers.ScaledDummy(2f); - ImGui.TextUnformatted("Suggested Syncshell Permissions:"); - - DrawPermissionRow("Sounds", _joinInfo.GroupPermissions.IsPreferDisableSounds(), _ownPermissions.DisableGroupSounds, v => _ownPermissions.DisableGroupSounds = v); - DrawPermissionRow("Animations", _joinInfo.GroupPermissions.IsPreferDisableAnimations(), _ownPermissions.DisableGroupAnimations, v => _ownPermissions.DisableGroupAnimations = v); - DrawPermissionRow("VFX", _joinInfo.GroupPermissions.IsPreferDisableVFX(), _ownPermissions.DisableGroupVFX, v => _ownPermissions.DisableGroupVFX = v); - - ImGui.NewLine(); - ImGui.NewLine(); - - if (_uiSharedService.IconTextButton(Dalamud.Interface.FontAwesomeIcon.Plus, $"Finalize and join {_joinDto.Group.AliasOrGID}")) - { - var finalPermissions = GroupUserPreferredPermissions.NoneSet; - finalPermissions.SetDisableSounds(_ownPermissions.DisableGroupSounds); - finalPermissions.SetDisableAnimations(_ownPermissions.DisableGroupAnimations); - finalPermissions.SetDisableVFX(_ownPermissions.DisableGroupVFX); - - _ = _apiController.GroupJoinFinalize(new GroupJoinDto(_joinDto.Group, _joinDto.Password, finalPermissions)); - - _recentlyJoined.Add(_joinDto.Group.GID); - - _joinDto = null; - _joinInfo = null; - } - } - } - - private void DrawPermissionRow(string label, bool suggested, bool current, Action apply) - { - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted($"- {label}"); - - ImGui.SameLine(150 * ImGuiHelpers.GlobalScale); - ImGui.TextUnformatted("Current:"); - ImGui.SameLine(); - _uiSharedService.BooleanToColoredIcon(!current); - - ImGui.SameLine(300 * ImGuiHelpers.GlobalScale); - ImGui.TextUnformatted("Suggested:"); - ImGui.SameLine(); - _uiSharedService.BooleanToColoredIcon(!suggested); - - ImGui.SameLine(450 * ImGuiHelpers.GlobalScale); - using var id = ImRaii.PushId(label); - if (current != suggested) - { - if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowRight, "Apply")) - apply(suggested); - } - - ImGui.NewLine(); - } - - private async Task RefreshSyncshellsAsync(string? gid = null) - { - var syncshellBroadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts(); - var snapshot = _pairUiService.GetSnapshot(); - _currentSyncshells = [.. snapshot.GroupPairs.Keys]; - - _recentlyJoined.RemoveWhere(gid => - _currentSyncshells.Exists(s => string.Equals(s.GID, gid, StringComparison.Ordinal))); - - List? updatedList = []; - - if (_useTestSyncshells) - { - updatedList = BuildTestSyncshells(); - } - else - { - if (syncshellBroadcasts.Count == 0) - { - ClearSyncshells(); - return; - } - - try - { - var groups = await _apiController.GetBroadcastedGroups(syncshellBroadcasts) - .ConfigureAwait(false); - updatedList = groups?.DistinctBy(g => g.Group.GID).ToList(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to refresh broadcasted syncshells."); - return; - } - } - - if (updatedList == null || updatedList.Count == 0) - { - ClearSyncshells(); - return; - } - - if (gid != null && _recentlyJoined.Contains(gid)) - { - _recentlyJoined.Clear(); - } - - var previousGid = GetSelectedGid(); - - _nearbySyncshells.Clear(); - _nearbySyncshells.AddRange(updatedList); - - if (previousGid != null) - { - var newIndex = _nearbySyncshells.FindIndex(s => - string.Equals(s.Group.GID, previousGid, StringComparison.Ordinal)); - - if (newIndex >= 0) - { - _selectedNearbyIndex = newIndex; - return; - } - } - - ClearSelection(); - } - - private static List BuildTestSyncshells() - { - var testGroup1 = new GroupData("TEST-ALPHA", "Alpha Shell"); - var testGroup2 = new GroupData("TEST-BETA", "Beta Shell"); - var testGroup3 = new GroupData("TEST-GAMMA", "Gamma Shell"); - var testGroup4 = new GroupData("TEST-DELTA", "Delta Shell"); - var testGroup5 = new GroupData("TEST-CHARLIE", "Charlie Shell"); - var testGroup6 = new GroupData("TEST-OMEGA", "Omega Shell"); - var testGroup7 = new GroupData("TEST-POINT", "Point Shell"); - var testGroup8 = new GroupData("TEST-HOTEL", "Hotel Shell"); - - return - [ - new(testGroup1, "", GroupUserPreferredPermissions.NoneSet), - new(testGroup2, "", GroupUserPreferredPermissions.NoneSet), - new(testGroup3, "", GroupUserPreferredPermissions.NoneSet), - new(testGroup4, "", GroupUserPreferredPermissions.NoneSet), - new(testGroup5, "", GroupUserPreferredPermissions.NoneSet), - new(testGroup6, "", GroupUserPreferredPermissions.NoneSet), - new(testGroup7, "", GroupUserPreferredPermissions.NoneSet), - new(testGroup8, "", GroupUserPreferredPermissions.NoneSet), - ]; - } - - private void ClearSyncshells() - { - if (_nearbySyncshells.Count == 0) - return; - - _nearbySyncshells.Clear(); - ClearSelection(); - } - - private void ClearSelection() - { - _selectedNearbyIndex = -1; - _syncshellPageIndex = 0; - _joinDto = null; - _joinInfo = null; - } - - private string? GetSelectedGid() - { - if (_selectedNearbyIndex < 0 || _selectedNearbyIndex >= _nearbySyncshells.Count) - return null; - - return _nearbySyncshells[_selectedNearbyIndex].Group.GID; - } - -} diff --git a/LightlessSync/UI/TopTabMenu.cs b/LightlessSync/UI/TopTabMenu.cs index cc69a5d..b63b631 100644 --- a/LightlessSync/UI/TopTabMenu.cs +++ b/LightlessSync/UI/TopTabMenu.cs @@ -162,24 +162,18 @@ public class TopTabMenu ImGui.SameLine(); using (ImRaii.PushFont(UiBuilder.IconFont)) { - var x = ImGui.GetCursorScreenPos(); if (ImGui.Button(FontAwesomeIcon.Compass.ToIconString(), buttonSize)) { - TabSelection = TabSelection == SelectedTab.Lightfinder ? SelectedTab.None : SelectedTab.Lightfinder; + _lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); } if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) { Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); } - ImGui.SameLine(); - var xAfter = ImGui.GetCursorScreenPos(); - if (TabSelection == SelectedTab.Lightfinder) - drawList.AddLine(x with { Y = x.Y + buttonSize.Y + spacing.Y }, - xAfter with { Y = xAfter.Y + buttonSize.Y + spacing.Y, X = xAfter.X - spacing.X }, - underlineColor, 2); + } - UiSharedService.AttachToolTip("Lightfinder"); + UiSharedService.AttachToolTip(GetLightfinderTooltip()); ImGui.SameLine(); using (ImRaii.PushFont(UiBuilder.IconFont)) @@ -234,10 +228,7 @@ public class TopTabMenu DrawSyncshellMenu(availableWidth, spacing.X); DrawGlobalSyncshellButtons(availableWidth, spacing.X); } - else if (TabSelection == SelectedTab.Lightfinder) - { - DrawLightfinderMenu(availableWidth, spacing.X); - } + else if (TabSelection == SelectedTab.UserConfig) { DrawUserConfig(availableWidth, spacing.X); @@ -779,26 +770,17 @@ public class TopTabMenu private void DrawLightfinderMenu(float availableWidth, float spacingX) { - var buttonX = (availableWidth - (spacingX)) / 2f; - - if (_uiSharedService.IconTextButton(FontAwesomeIcon.PersonCirclePlus, "Lightfinder", buttonX, center: true)) + var lightfinderLabel = GetLightfinderTooltip(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.PersonCirclePlus, lightfinderLabel, availableWidth, center: true)) { _lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); } - - ImGui.SameLine(); - - var syncshellFinderLabel = GetSyncshellFinderLabel(); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Globe, syncshellFinderLabel, buttonX, center: true)) - { - _lightlessMediator.Publish(new UiToggleMessage(typeof(SyncshellFinderUI))); - } } - private string GetSyncshellFinderLabel() + private string GetLightfinderTooltip() { if (!_lightFinderService.IsBroadcasting) - return "Syncshell Finder"; + return "Open Lightfinder"; string? myHashedCid = null; try @@ -820,7 +802,7 @@ public class TopTabMenu .Distinct(StringComparer.Ordinal) .Count(); - return nearbyCount > 0 ? $"Syncshell Finder ({nearbyCount})" : "Syncshell Finder"; + return nearbyCount > 0 ? $"Lightfinder ({nearbyCount} nearby)" : "Open Lightfinder"; } private void DrawUserConfig(float availableWidth, float spacingX) diff --git a/LightlessSync/WebAPI/Files/FileDownloadManager.cs b/LightlessSync/WebAPI/Files/FileDownloadManager.cs index 49dd868..de9bca7 100644 --- a/LightlessSync/WebAPI/Files/FileDownloadManager.cs +++ b/LightlessSync/WebAPI/Files/FileDownloadManager.cs @@ -861,9 +861,17 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase { if (downloadCt.IsCancellationRequested) throw; - var req = await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCheckQueueFullPath(downloadFileTransfer[0].DownloadUri, requestId), - downloadFileTransfer.Select(c => c.Hash).ToList(), downloadCt).ConfigureAwait(false); - req.EnsureSuccessStatusCode(); + try + { + var req = await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCheckQueueFullPath(downloadFileTransfer[0].DownloadUri, requestId), + downloadFileTransfer.Select(c => c.Hash).ToList(), downloadCt).ConfigureAwait(false); + req.EnsureSuccessStatusCode(); + } + catch (Exception ex) when (!downloadCt.IsCancellationRequested) + { + Logger.LogDebug(ex, "Transient error checking queue status for {requestId}, will retry", requestId); + } + localTimeoutCts.Dispose(); composite.Dispose(); localTimeoutCts = new(); -- 2.49.1 From 1ab4e2f94be84649d8a4737da52a8f7311a31afa Mon Sep 17 00:00:00 2001 From: cake Date: Fri, 26 Dec 2025 22:26:29 +0100 Subject: [PATCH 07/87] Added color options for header --- LightlessSync/UI/SettingsUi.cs | 25 ++++++----- LightlessSync/UI/Style/AnimatedHeader.cs | 53 +++++++++++++++++++----- LightlessSync/UI/UIColors.cs | 16 ++++--- 3 files changed, 68 insertions(+), 26 deletions(-) diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 91f69d5..a6b9c33 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -2795,16 +2795,21 @@ public class SettingsUi : WindowMediatorSubscriberBase var colorNames = new[] { - ("LightlessPurple", "Primary Purple", "Section titles and dividers"), - ("LightlessPurpleActive", "Primary Purple (Active)", "Active tabs and hover highlights"), - ("LightlessPurpleDefault", "Primary Purple (Inactive)", "Inactive tabs and default dividers"), - ("LightlessBlue", "Secondary Blue", "Secondary title colors, visable pairs"), - ("LightlessGreen", "Success Green", "Join buttons and success messages"), - ("LightlessYellow", "Warning Yellow", "Warning colors"), - ("LightlessOrange", "Performance Orange", "Performance notifications and warnings"), - ("PairBlue", "Syncshell Blue", "Syncshell headers, toggle highlights, and moderator actions"), - ("DimRed", "Error Red", "Error and offline colors") - }; + ("LightlessPurple", "Primary Purple", "Section titles and dividers"), + ("LightlessPurpleActive", "Primary Purple (Active)", "Active tabs and hover highlights"), + ("LightlessPurpleDefault", "Primary Purple (Inactive)", "Inactive tabs and default dividers"), + ("LightlessBlue", "Secondary Blue", "Secondary title colors, visable pairs"), + ("LightlessGreen", "Success Green", "Join buttons and success messages"), + ("LightlessYellow", "Warning Yellow", "Warning colors"), + ("LightlessOrange", "Performance Orange", "Performance notifications and warnings"), + ("PairBlue", "Syncshell Blue", "Syncshell headers, toggle highlights, and moderator actions"), + ("DimRed", "Error Red", "Error and offline colors"), + ("HeaderGradientTop", "Header Gradient (Top)", "Top color of the animated header background"), + ("HeaderGradientBottom", "Header Gradient (Bottom)", "Bottom color of the animated header background"), + ("HeaderStaticStar", "Header Stars", "Tint color for the static background stars in the header"), + ("HeaderShootingStar", "Header Shooting Star", "Tint color for the shooting star effect"), + }; + if (ImGui.BeginTable("##ColorTable", 3, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit)) { diff --git a/LightlessSync/UI/Style/AnimatedHeader.cs b/LightlessSync/UI/Style/AnimatedHeader.cs index 839c704..9df0847 100644 --- a/LightlessSync/UI/Style/AnimatedHeader.cs +++ b/LightlessSync/UI/Style/AnimatedHeader.cs @@ -43,8 +43,19 @@ public class AnimatedHeader private const float _extendedParticleHeight = 40f; public float Height { get; set; } = 150f; + + // Color keys for theming + public string? TopColorKey { get; set; } = "HeaderGradientTop"; + public string? BottomColorKey { get; set; } = "HeaderGradientBottom"; + public string? StaticStarColorKey { get; set; } = "HeaderStaticStar"; + public string? ShootingStarColorKey { get; set; } = "HeaderShootingStar"; + + // Fallbacks if the color keys are not found public Vector4 TopColor { get; set; } = new(0.08f, 0.05f, 0.15f, 1.0f); public Vector4 BottomColor { get; set; } = new(0.12f, 0.08f, 0.20f, 1.0f); + public Vector4 StaticStarColor { get; set; } = new(1f, 1f, 1f, 1f); + public Vector4 ShootingStarColor { get; set; } = new(0.4f, 0.8f, 1.0f, 1.0f); + public bool EnableParticles { get; set; } = true; public bool EnableBottomGradient { get; set; } = true; @@ -148,16 +159,21 @@ public class AnimatedHeader { var drawList = ImGui.GetWindowDrawList(); + var top = ResolveColor(TopColorKey, TopColor); + var bottom = ResolveColor(BottomColorKey, BottomColor); + drawList.AddRectFilledMultiColor( headerStart, headerEnd, - ImGui.GetColorU32(TopColor), - ImGui.GetColorU32(TopColor), - ImGui.GetColorU32(BottomColor), - ImGui.GetColorU32(BottomColor) + ImGui.GetColorU32(top), + ImGui.GetColorU32(top), + ImGui.GetColorU32(bottom), + ImGui.GetColorU32(bottom) ); // Draw static background stars + var starBase = ResolveColor(StaticStarColorKey, StaticStarColor); + var random = new Random(42); for (int i = 0; i < 50; i++) { @@ -166,7 +182,9 @@ public class AnimatedHeader (float)random.NextDouble() * (headerEnd.Y - headerStart.Y) ); var brightness = 0.3f + (float)random.NextDouble() * 0.4f; - drawList.AddCircleFilled(starPos, 1f, ImGui.GetColorU32(new Vector4(1f, 1f, 1f, brightness))); + var starColor = starBase with { W = starBase.W * brightness }; + + drawList.AddCircleFilled(starPos, 1f, ImGui.GetColorU32(starColor)); } } @@ -174,15 +192,18 @@ public class AnimatedHeader { var drawList = ImGui.GetWindowDrawList(); var gradientHeight = GradientHeight; + var bottom = ResolveColor(BottomColorKey, BottomColor); for (int i = 0; i < gradientHeight; i++) { var progress = i / gradientHeight; var smoothProgress = progress * progress; - var r = BottomColor.X + (0.0f - BottomColor.X) * smoothProgress; - var g = BottomColor.Y + (0.0f - BottomColor.Y) * smoothProgress; - var b = BottomColor.Z + (0.0f - BottomColor.Z) * smoothProgress; + + var r = bottom.X + (0.0f - bottom.X) * smoothProgress; + var g = bottom.Y + (0.0f - bottom.Y) * smoothProgress; + var b = bottom.Z + (0.0f - bottom.Z) * smoothProgress; var alpha = 1f - smoothProgress; + var gradientColor = new Vector4(r, g, b, alpha); drawList.AddLine( new Vector2(headerStart.X, headerEnd.Y + i), @@ -310,9 +331,11 @@ public class AnimatedHeader ? baseAlpha * (0.6f + 0.4f * MathF.Sin(particle.Twinkle)) : baseAlpha; + var shootingBase = ResolveColor(ShootingStarColorKey, ShootingStarColor); + if (particle.Type == ParticleType.ShootingStar && particle.Trail != null && particle.Trail.Count > 1) { - var cyanColor = new Vector4(0.4f, 0.8f, 1.0f, 1.0f); + var baseColor = shootingBase; for (int t = 1; t < particle.Trail.Count; t++) { @@ -321,17 +344,18 @@ public class AnimatedHeader var trailWidth = (1f - trailProgress) * 3f + 1f; var glowAlpha = trailAlpha * 0.4f; + drawList.AddLine( bannerStart + particle.Trail[t - 1], bannerStart + particle.Trail[t], - ImGui.GetColorU32(cyanColor with { W = glowAlpha }), + ImGui.GetColorU32(baseColor with { W = glowAlpha }), trailWidth + 4f ); drawList.AddLine( bannerStart + particle.Trail[t - 1], bannerStart + particle.Trail[t], - ImGui.GetColorU32(cyanColor with { W = trailAlpha }), + ImGui.GetColorU32(baseColor with { W = trailAlpha }), trailWidth ); } @@ -450,6 +474,13 @@ public class AnimatedHeader Hue = 270f }); } + private static Vector4 ResolveColor(string? key, Vector4 fallback) + { + if (string.IsNullOrWhiteSpace(key)) + return fallback; + + return UIColors.Get(key); + } /// /// Clears all active particles. Useful when closing or hiding a window with an animated header. diff --git a/LightlessSync/UI/UIColors.cs b/LightlessSync/UI/UIColors.cs index 9d7f770..1decb77 100644 --- a/LightlessSync/UI/UIColors.cs +++ b/LightlessSync/UI/UIColors.cs @@ -6,7 +6,7 @@ namespace LightlessSync.UI { internal static class UIColors { - private static readonly Dictionary DefaultHexColors = new(StringComparer.OrdinalIgnoreCase) + private static readonly Dictionary _defaultHexColors = new(StringComparer.OrdinalIgnoreCase) { { "LightlessPurple", "#ad8af5" }, { "LightlessPurpleActive", "#be9eff" }, @@ -31,6 +31,12 @@ namespace LightlessSync.UI { "ProfileBodyGradientTop", "#2f283fff" }, { "ProfileBodyGradientBottom", "#372d4d00" }, + + { "HeaderGradientTop", "#140D26FF" }, + { "HeaderGradientBottom", "#1F1433FF" }, + + { "HeaderStaticStar", "#FFFFFFFF" }, + { "HeaderShootingStar", "#66CCFFFF" }, }; private static LightlessConfigService? _configService; @@ -45,7 +51,7 @@ namespace LightlessSync.UI if (_configService?.Current.CustomUIColors.TryGetValue(name, out var customColorHex) == true) return HexToRgba(customColorHex); - if (!DefaultHexColors.TryGetValue(name, out var hex)) + if (!_defaultHexColors.TryGetValue(name, out var hex)) throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name)); return HexToRgba(hex); @@ -53,7 +59,7 @@ namespace LightlessSync.UI public static void Set(string name, Vector4 color) { - if (!DefaultHexColors.ContainsKey(name)) + if (!_defaultHexColors.ContainsKey(name)) throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name)); if (_configService != null) @@ -83,7 +89,7 @@ namespace LightlessSync.UI public static Vector4 GetDefault(string name) { - if (!DefaultHexColors.TryGetValue(name, out var hex)) + if (!_defaultHexColors.TryGetValue(name, out var hex)) throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name)); return HexToRgba(hex); @@ -96,7 +102,7 @@ namespace LightlessSync.UI public static IEnumerable GetColorNames() { - return DefaultHexColors.Keys; + return _defaultHexColors.Keys; } public static Vector4 HexToRgba(string hexColor) -- 2.49.1 From 5eed65149ae3cf35c684efd746ccee492e783f93 Mon Sep 17 00:00:00 2001 From: choco Date: Sat, 27 Dec 2025 02:38:56 +0100 Subject: [PATCH 08/87] nearby lightfinder users window, wiht pair func --- LightlessSync/Plugin.cs | 3 +- LightlessSync/Services/DalamudUtilService.cs | 22 ++ LightlessSync/UI/CompactUI.cs | 3 +- LightlessSync/UI/LightFinderUI.cs | 374 +++++++++++++++++-- 4 files changed, 366 insertions(+), 36 deletions(-) diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 606a23d..d4e13b3 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -462,7 +462,8 @@ public sealed class Plugin : IDalamudPlugin sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService())); services.AddScoped(); services.AddScoped(); diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 106adf2..5908624 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -776,6 +776,28 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber return WorldData.Value.TryGetValue(worldId, out var worldName) ? worldName : null; } + public void TargetPlayerByAddress(nint address) + { + if (address == nint.Zero) return; + if (_clientState.IsPvP) return; + + _ = RunOnFrameworkThread(() => + { + var gameObject = CreateGameObject(address); + if (gameObject is null) return; + + var useFocusTarget = _configService.Current.UseFocusTarget; + if (useFocusTarget) + { + _targetManager.FocusTarget = gameObject; + } + else + { + _targetManager.Target = gameObject; + } + }); + } + private unsafe void CheckCharacterForDrawing(nint address, string characterName) { var gameObj = (GameObject*)address; diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index be56434..79fbc88 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -519,7 +519,7 @@ public class CompactUi : WindowMediatorSubscriberBase ImGui.InvisibleButton("BroadcastIcon", buttonSize); var iconPos = ImGui.GetItemRectMin() + new Vector2(0f, iconYOffset); using (_uiSharedService.IconFont.Push()) - ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(UIColors.Get("LightlessGreen")), FontAwesomeIcon.PersonCirclePlus.ToIconString()); + ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(UIColors.Get("LightlessGreen")), FontAwesomeIcon.Wifi.ToIconString()); nextIconX = ImGui.GetItemRectMax().X + 6f; @@ -640,6 +640,7 @@ public class CompactUi : WindowMediatorSubscriberBase } } + if (uidTextHovered) { var padding = new Vector2(35f * ImGuiHelpers.GlobalScale); diff --git a/LightlessSync/UI/LightFinderUI.cs b/LightlessSync/UI/LightFinderUI.cs index b6bfc73..ed55d4f 100644 --- a/LightlessSync/UI/LightFinderUI.cs +++ b/LightlessSync/UI/LightFinderUI.cs @@ -11,7 +11,9 @@ using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto; using LightlessSync.API.Dto.Group; using LightlessSync.LightlessConfiguration; +using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; +using LightlessSync.Services.ActorTracking; using LightlessSync.Services.LightFinder; using LightlessSync.Services.Mediator; using LightlessSync.UI.Services; @@ -28,6 +30,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase { #region Services + private readonly ActorObjectService _actorObjectService; private readonly ApiController _apiController; private readonly DalamudUtilService _dalamudUtilService; private readonly LightFinderScannerService _broadcastScannerService; @@ -63,10 +66,10 @@ public class LightFinderUI : WindowMediatorSubscriberBase private LightfinderTab _selectedTab = LightfinderTab.NearbySyncshells; private string _userUid = string.Empty; - private enum LightfinderTab { NearbySyncshells, BroadcastSettings, Help } + private enum LightfinderTab { NearbySyncshells, NearbyPlayers, BroadcastSettings, Help } #if DEBUG - private enum LightfinderTabDebug { NearbySyncshells, BroadcastSettings, Help, Debug } + private enum LightfinderTabDebug { NearbySyncshells, NearbyPlayers, BroadcastSettings, Help, Debug } private LightfinderTabDebug _selectedTabDebug = LightfinderTabDebug.NearbySyncshells; #endif @@ -89,7 +92,8 @@ public class LightFinderUI : WindowMediatorSubscriberBase LightFinderScannerService broadcastScannerService, PairUiService pairUiService, DalamudUtilService dalamudUtilService, - LightlessProfileManager lightlessProfileManager + LightlessProfileManager lightlessProfileManager, + ActorObjectService actorObjectService ) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService) { _broadcastService = broadcastService; @@ -100,6 +104,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase _pairUiService = pairUiService; _dalamudUtilService = dalamudUtilService; _lightlessProfileManager = lightlessProfileManager; + _actorObjectService = actorObjectService; _animatedHeader.Height = 100f; _animatedHeader.EnableBottomGradient = true; @@ -158,6 +163,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase var debugTabOptions = new List> { new("Nearby Syncshells", LightfinderTabDebug.NearbySyncshells), + new("Nearby Players", LightfinderTabDebug.NearbyPlayers), new("Broadcast", LightfinderTabDebug.BroadcastSettings), new("Help", LightfinderTabDebug.Help), new("Debug", LightfinderTabDebug.Debug) @@ -171,6 +177,9 @@ public class LightFinderUI : WindowMediatorSubscriberBase case LightfinderTabDebug.NearbySyncshells: DrawNearbySyncshellsTab(); break; + case LightfinderTabDebug.NearbyPlayers: + DrawNearbyPlayersTab(); + break; case LightfinderTabDebug.BroadcastSettings: DrawBroadcastSettingsTab(); break; @@ -185,6 +194,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase var tabOptions = new List> { new("Nearby Syncshells", LightfinderTab.NearbySyncshells), + new("Nearby Players", LightfinderTab.NearbyPlayers), new("Broadcast", LightfinderTab.BroadcastSettings), new("Help", LightfinderTab.Help) }; @@ -197,6 +207,9 @@ public class LightFinderUI : WindowMediatorSubscriberBase case LightfinderTab.NearbySyncshells: DrawNearbySyncshellsTab(); break; + case LightfinderTab.NearbyPlayers: + DrawNearbyPlayersTab(); + break; case LightfinderTab.BroadcastSettings: DrawBroadcastSettingsTab(); break; @@ -241,8 +254,8 @@ public class LightFinderUI : WindowMediatorSubscriberBase { ImGui.TableSetupColumn("Status", ImGuiTableColumnFlags.WidthStretch, 1f); ImGui.TableSetupColumn("Time", ImGuiTableColumnFlags.WidthStretch, 1f); - ImGui.TableSetupColumn("NearbyPlayers", ImGuiTableColumnFlags.WidthStretch, 1f); ImGui.TableSetupColumn("NearbySyncshells", ImGuiTableColumnFlags.WidthStretch, 1f); + ImGui.TableSetupColumn("NearbyPlayers", ImGuiTableColumnFlags.WidthStretch, 1f); ImGui.TableSetupColumn("Broadcasting", ImGuiTableColumnFlags.WidthStretch, 1f); ImGui.TableSetupColumn("Button", ImGuiTableColumnFlags.WidthFixed, buttonWidth + 16f * scale); @@ -278,16 +291,18 @@ public class LightFinderUI : WindowMediatorSubscriberBase } DrawStatusCell(FontAwesomeIcon.Clock, timeColor, timeValue, timeSub, infoColor, scale); - // Nearby players cell - var nearbyPlayerCount = _broadcastScannerService.CountActiveBroadcasts(); - var nearbyPlayerColor = nearbyPlayerCount > 0 ? UIColors.Get("LightlessBlue") : infoColor; - DrawStatusCell(FontAwesomeIcon.Users, nearbyPlayerColor, nearbyPlayerCount.ToString(), "Players", infoColor, scale); - // Nearby syncshells cell var nearbySyncshellCount = _nearbySyncshells.Count; var nearbySyncshellColor = nearbySyncshellCount > 0 ? UIColors.Get("LightlessPurple") : infoColor; DrawStatusCell(FontAwesomeIcon.Compass, nearbySyncshellColor, nearbySyncshellCount.ToString(), "Syncshells", infoColor, scale); - + + // Nearby players cell (exclude self) + string? myHashedCidForCount = null; + try { myHashedCidForCount = _dalamudUtilService.GetCID().ToString().GetHash256(); } catch { } + var nearbyPlayerCount = _broadcastScannerService.CountActiveBroadcasts(myHashedCidForCount); + var nearbyPlayerColor = nearbyPlayerCount > 0 ? UIColors.Get("LightlessBlue") : infoColor; + DrawStatusCell(FontAwesomeIcon.Users, nearbyPlayerColor, nearbyPlayerCount.ToString(), "Players", infoColor, scale); + // Broadcasting syncshell cell var isBroadcastingSyncshell = _configService.Current.SyncshellFinderEnabled && isBroadcasting; var broadcastSyncshellColor = isBroadcastingSyncshell ? UIColors.Get("LightlessGreen") : infoColor; @@ -409,7 +424,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase } } - private List<(GroupJoinDto Shell, string BroadcasterName)> BuildSyncshellCardData() + private List<(GroupJoinDto Shell, string BroadcasterName, bool IsOwnBroadcast)> BuildSyncshellCardData() { string? myHashedCid = null; try @@ -419,11 +434,9 @@ public class LightFinderUI : WindowMediatorSubscriberBase } catch { } - var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts() - .Where(b => !string.Equals(b.HashedCID, myHashedCid, StringComparison.Ordinal)) - .ToList(); + var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts().ToList(); - var cardData = new List<(GroupJoinDto Shell, string BroadcasterName)>(); + var cardData = new List<(GroupJoinDto Shell, string BroadcasterName, bool IsOwnBroadcast)>(); foreach (var shell in _nearbySyncshells) { @@ -434,7 +447,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase if (_useTestSyncshells) { var displayName = !string.IsNullOrEmpty(shell.Group.Alias) ? shell.Group.Alias : shell.Group.GID; - cardData.Add((shell, $"{displayName} (Test World)")); + cardData.Add((shell, $"{displayName} (Test World)", false)); continue; } #endif @@ -443,29 +456,39 @@ public class LightFinderUI : WindowMediatorSubscriberBase if (broadcast == null) continue; - var (name, address) = _dalamudUtilService.FindPlayerByNameHash(broadcast.HashedCID); - if (string.IsNullOrEmpty(name)) - continue; + var isOwnBroadcast = !string.IsNullOrEmpty(myHashedCid) && string.Equals(broadcast.HashedCID, myHashedCid, StringComparison.Ordinal); - var worldName = _dalamudUtilService.GetWorldNameFromPlayerAddress(address); - var broadcasterName = !string.IsNullOrEmpty(worldName) ? $"{name} ({worldName})" : name; + string broadcasterName; + if (isOwnBroadcast) + { + broadcasterName = "You"; + } + else + { + var (name, address) = _dalamudUtilService.FindPlayerByNameHash(broadcast.HashedCID); + if (string.IsNullOrEmpty(name)) + continue; - cardData.Add((shell, broadcasterName)); + var worldName = _dalamudUtilService.GetWorldNameFromPlayerAddress(address); + broadcasterName = !string.IsNullOrEmpty(worldName) ? $"{name} ({worldName})" : name; + } + + cardData.Add((shell, broadcasterName, isOwnBroadcast)); } return cardData; } - private void DrawSyncshellList(List<(GroupJoinDto Shell, string BroadcasterName)> listData) + private void DrawSyncshellList(List<(GroupJoinDto Shell, string BroadcasterName, bool IsOwnBroadcast)> listData) { ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8f); ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1f); if (ImGui.BeginChild("SyncshellListScroll", new Vector2(-1, -1), border: false)) { - foreach (var (shell, broadcasterName) in listData) + foreach (var (shell, broadcasterName, isOwnBroadcast) in listData) { - DrawSyncshellListItem(shell, broadcasterName); + DrawSyncshellListItem(shell, broadcasterName, isOwnBroadcast); ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); } } @@ -474,7 +497,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase ImGui.PopStyleVar(2); } - private void DrawSyncshellListItem(GroupJoinDto shell, string broadcasterName) + private void DrawSyncshellListItem(GroupJoinDto shell, string broadcasterName, bool isOwnBroadcast) { ImGui.PushID(shell.Group.GID); float rowHeight = 74f * ImGuiHelpers.GlobalScale; @@ -496,9 +519,12 @@ public class LightFinderUI : WindowMediatorSubscriberBase float rightX = startX + regionW - rightTxtW - style.ItemSpacing.X; ImGui.SameLine(); ImGui.SetCursorPosX(rightX); - ImGui.TextUnformatted(broadcasterName); + if (isOwnBroadcast) + ImGui.TextColored(UIColors.Get("LightlessGreen"), broadcasterName); + else + ImGui.TextUnformatted(broadcasterName); if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Broadcaster"); + ImGui.SetTooltip(isOwnBroadcast ? "Your broadcast" : "Broadcaster"); UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); @@ -530,21 +556,21 @@ public class LightFinderUI : WindowMediatorSubscriberBase ImGui.PopID(); } - private void DrawSyncshellGrid(List<(GroupJoinDto Shell, string BroadcasterName)> cardData) + private void DrawSyncshellGrid(List<(GroupJoinDto Shell, string BroadcasterName, bool IsOwnBroadcast)> cardData) { ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8f); ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1f); - foreach (var (shell, _) in cardData) + foreach (var (shell, _, isOwnBroadcast) in cardData) { - DrawSyncshellCompactItem(shell); + DrawSyncshellCompactItem(shell, isOwnBroadcast); ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); } ImGui.PopStyleVar(2); } - private void DrawSyncshellCompactItem(GroupJoinDto shell) + private void DrawSyncshellCompactItem(GroupJoinDto shell, bool isOwnBroadcast) { ImGui.PushID(shell.Group.GID); float rowHeight = 36f * ImGuiHelpers.GlobalScale; @@ -552,11 +578,13 @@ public class LightFinderUI : WindowMediatorSubscriberBase ImGui.BeginChild($"ShellCompact##{shell.Group.GID}", new Vector2(-1, rowHeight), border: true); var displayName = !string.IsNullOrEmpty(shell.Group.Alias) ? shell.Group.Alias : shell.Group.GID; + if (isOwnBroadcast) + displayName += " (You)"; var style = ImGui.GetStyle(); float availW = ImGui.GetContentRegionAvail().X; ImGui.AlignTextToFramePadding(); - _uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple")); + _uiSharedService.MediumText(displayName, isOwnBroadcast ? UIColors.Get("LightlessGreen") : UIColors.Get("LightlessPurple")); if (ImGui.IsItemHovered()) ImGui.SetTooltip("Click to open profile."); if (ImGui.IsItemClicked()) @@ -605,7 +633,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase using (ImRaii.Disabled()) ImGui.Button(label, buttonSize); - UiSharedService.AttachToolTip("You can't join your own Syncshell, silly! That's like trying to high-five yourself."); + UiSharedService.AttachToolTip("You can't join your own Syncshell..."); } else if (!isAlreadyMember && !isRecentlyJoined) { @@ -718,6 +746,284 @@ public class LightFinderUI : WindowMediatorSubscriberBase #endregion + #region Nearby Players Tab + + private void DrawNearbyPlayersTab() + { + ImGui.BeginGroup(); + string checkboxLabel = "Compact"; + float checkboxWidth = ImGui.CalcTextSize(checkboxLabel).X + ImGui.GetFrameHeight() + 8f; + float availWidth = ImGui.GetContentRegionAvail().X; + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + availWidth - checkboxWidth); + ImGui.Checkbox(checkboxLabel, ref _compactView); + ImGui.EndGroup(); + + ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); + + if (!_broadcastService.IsBroadcasting) + { + ImGui.TextColored(UIColors.Get("LightlessYellow"), "Lightfinder must be active to see nearby players."); + return; + } + + string? myHashedCid = null; + try + { + var cid = _dalamudUtilService.GetCID(); + myHashedCid = cid.ToString().GetHash256(); + } + catch { } + + var activeBroadcasts = _broadcastScannerService.GetActiveBroadcasts(myHashedCid); + + if (activeBroadcasts.Count == 0) + { + ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby Lightfinder players found."); + return; + } + + var playerData = BuildNearbyPlayerData(activeBroadcasts); + if (playerData.Count == 0) + { + ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby Lightfinder players found."); + return; + } + + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8f); + ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1f); + + if (ImGui.BeginChild("NearbyPlayersScroll", new Vector2(-1, -1), border: false)) + { + foreach (var data in playerData) + { + if (_compactView) + DrawNearbyPlayerCompactRow(data); + else + DrawNearbyPlayerRow(data); + ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); + } + } + ImGui.EndChild(); + + ImGui.PopStyleVar(2); + } + + private List BuildNearbyPlayerData(List> activeBroadcasts) + { + var snapshot = _pairUiService.GetSnapshot(); + var playerData = new List(); + + foreach (var broadcast in activeBroadcasts) + { + var (name, address) = _dalamudUtilService.FindPlayerByNameHash(broadcast.Key); + if (string.IsNullOrEmpty(name) || address == nint.Zero) + continue; + + var worldName = _dalamudUtilService.GetWorldNameFromPlayerAddress(address); + var pair = snapshot.PairsByUid.Values.FirstOrDefault(p => + p.IsVisible && + !string.IsNullOrEmpty(p.GetPlayerNameHash()) && + string.Equals(p.GetPlayerNameHash(), broadcast.Key, StringComparison.Ordinal)); + + var isDirectlyPaired = pair?.IsDirectlyPaired ?? false; + var sharedGroups = pair?.UserPair?.Groups ?? []; + var sharedGroupNames = sharedGroups + .Select(gid => snapshot.GroupsByGid.TryGetValue(gid, out var g) ? g.GroupAliasOrGID : gid) + .ToList(); + + playerData.Add(new NearbyPlayerData(broadcast.Key, name, worldName, address, pair, isDirectlyPaired, sharedGroupNames)); + } + + return playerData; + } + + private readonly record struct NearbyPlayerData( + string HashedCid, + string Name, + string? World, + nint Address, + Pair? Pair, + bool IsDirectlyPaired, + List SharedSyncshells); + + private void DrawNearbyPlayerRow(NearbyPlayerData data) + { + ImGui.PushID(data.HashedCid); + float rowHeight = 74f * ImGuiHelpers.GlobalScale; + + ImGui.BeginChild($"PlayerRow##{data.HashedCid}", new Vector2(-1, rowHeight), border: true); + + var serverName = !string.IsNullOrEmpty(data.World) ? data.World : "Unknown"; + var style = ImGui.GetStyle(); + float startX = ImGui.GetCursorPosX(); + float regionW = ImGui.GetContentRegionAvail().X; + float rightTxtW = ImGui.CalcTextSize(serverName).X; + + _uiSharedService.MediumText(data.Name, UIColors.Get("LightlessPurple")); + if (data.Pair != null) + { + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Click to open profile."); + if (ImGui.IsItemClicked()) + Mediator.Publish(new ProfileOpenStandaloneMessage(data.Pair)); + } + + float rightX = startX + regionW - rightTxtW - style.ItemSpacing.X; + ImGui.SameLine(); + ImGui.SetCursorPosX(rightX); + ImGui.TextUnformatted(serverName); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Home World"); + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); + + Vector2 rowStartLocal = ImGui.GetCursorPos(); + + if (data.IsDirectlyPaired) + { + ImGui.SetCursorPosX(startX); + _uiSharedService.IconText(FontAwesomeIcon.UserCheck, UIColors.Get("LightlessGreen")); + ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale); + ImGui.TextColored(UIColors.Get("LightlessGreen"), "Direct Pair"); + } + else if (data.SharedSyncshells.Count > 0) + { + ImGui.SetCursorPosX(startX); + _uiSharedService.IconText(FontAwesomeIcon.Users, UIColors.Get("LightlessPurple")); + ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale); + var shellText = data.SharedSyncshells.Count == 1 + ? data.SharedSyncshells[0] + : $"{data.SharedSyncshells.Count} shared shells"; + ImGui.TextColored(UIColors.Get("LightlessPurple"), shellText); + if (data.SharedSyncshells.Count > 1 && ImGui.IsItemHovered()) + ImGui.SetTooltip(string.Join("\n", data.SharedSyncshells)); + } + else + { + ImGui.SetCursorPosX(startX); + _uiSharedService.IconText(FontAwesomeIcon.Wifi, UIColors.Get("LightlessBlue")); + ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale); + ImGui.TextColored(ImGuiColors.DalamudGrey, "Lightfinder User"); + } + + ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); + + DrawPlayerActionButtons(data, startX, regionW, rowStartLocal.Y, style); + + ImGui.EndChild(); + ImGui.PopID(); + } + + private void DrawNearbyPlayerCompactRow(NearbyPlayerData data) + { + ImGui.PushID(data.HashedCid); + float rowHeight = 36f * ImGuiHelpers.GlobalScale; + + ImGui.BeginChild($"PlayerCompact##{data.HashedCid}", new Vector2(-1, rowHeight), border: true); + + ImGui.AlignTextToFramePadding(); + + if (data.IsDirectlyPaired) + { + _uiSharedService.IconText(FontAwesomeIcon.UserCheck, UIColors.Get("LightlessGreen")); + ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale); + } + else if (data.SharedSyncshells.Count > 0) + { + _uiSharedService.IconText(FontAwesomeIcon.Users, UIColors.Get("LightlessPurple")); + ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale); + } + else + { + _uiSharedService.IconText(FontAwesomeIcon.Wifi, UIColors.Get("LightlessBlue")); + ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale); + } + + var displayText = !string.IsNullOrEmpty(data.World) ? $"{data.Name} ({data.World})" : data.Name; + _uiSharedService.MediumText(displayText, UIColors.Get("LightlessPurple")); + if (data.Pair != null) + { + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Click to open profile."); + if (ImGui.IsItemClicked()) + Mediator.Publish(new ProfileOpenStandaloneMessage(data.Pair)); + } + + ImGui.SameLine(); + DrawPlayerActionButtons(data, 0, ImGui.GetContentRegionAvail().X, ImGui.GetCursorPosY(), ImGui.GetStyle(), compact: true); + + ImGui.EndChild(); + ImGui.PopID(); + } + + private void DrawPlayerActionButtons(NearbyPlayerData data, float startX, float regionW, float rowY, ImGuiStylePtr style, bool compact = false) + { + float buttonWidth = compact ? 60f * ImGuiHelpers.GlobalScale : 80f * ImGuiHelpers.GlobalScale; + float buttonHeight = compact ? 0 : 30f; + float totalButtonsWidth = buttonWidth * 2 + style.ItemSpacing.X; + + if (compact) + { + float availX = ImGui.GetContentRegionAvail().X; + float curX = ImGui.GetCursorPosX(); + ImGui.SetCursorPosX(curX + availX - totalButtonsWidth); + } + else + { + ImGui.SetCursorPos(new Vector2(startX + regionW - totalButtonsWidth - style.ItemSpacing.X, rowY)); + } + + using (ImRaii.PushColor(ImGuiCol.Button, UIColors.Get("LightlessGreen"))) + using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f))) + using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreen").WithAlpha(0.75f))) + using (ImRaii.Disabled(data.IsDirectlyPaired)) + { + if (ImGui.Button($"Pair##{data.HashedCid}", new Vector2(buttonWidth, buttonHeight))) + { + _ = SendPairRequestAsync(data.HashedCid); + } + } + if (data.IsDirectlyPaired) + UiSharedService.AttachToolTip("Already directly paired with this player."); + + ImGui.SameLine(); + + using (ImRaii.PushColor(ImGuiCol.Button, UIColors.Get("LightlessPurple"))) + using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurple").WithAlpha(0.85f))) + using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessPurple").WithAlpha(0.75f))) + { + if (ImGui.Button($"Target##{data.HashedCid}", new Vector2(buttonWidth, buttonHeight))) + { + TargetPlayerByAddress(data.Address); + } + } + } + + private async Task SendPairRequestAsync(string hashedCid) + { + if (string.IsNullOrWhiteSpace(hashedCid)) + return; + + try + { + await _apiController.TryPairWithContentId(hashedCid).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send pair request to {HashedCid}", hashedCid); + } + } + + private void TargetPlayerByAddress(nint address) + { + if (address == nint.Zero) + return; + + _dalamudUtilService.TargetPlayerByAddress(address); + } + + #endregion + #region Broadcast Settings Tab private void DrawBroadcastSettingsTab() -- 2.49.1 From 5c8e239a7b77191f9b1f125b5666a0f8a56f2930 Mon Sep 17 00:00:00 2001 From: Tsubasahane Date: Sat, 27 Dec 2025 17:04:39 +0800 Subject: [PATCH 09/87] implement playerState - use IPlayerState for DalamudUtilService and make things less asynced - make LocationInfo work with ContentFinderData --- .gitignore | 3 + .../FileCache/TransientResourceManager.cs | 2 +- LightlessSync/Plugin.cs | 3 +- .../CharaDataGposeTogetherManager.cs | 2 +- .../Services/CharaData/CharaDataManager.cs | 2 +- .../CharaData/CharaDataNearbyManager.cs | 4 +- .../Services/Chat/ZoneChatService.cs | 6 +- LightlessSync/Services/ContextMenuService.cs | 2 +- LightlessSync/Services/DalamudUtilService.cs | 174 ++++++++++-------- .../LightFinder/LightFinderService.cs | 2 +- .../ServerConfigurationManager.cs | 22 +-- LightlessSync/UI/DtrEntry.cs | 2 +- LightlessSync/UI/TopTabMenu.cs | 2 +- .../SignalR/ApIController.Functions.Users.cs | 2 +- LightlessSync/WebAPI/SignalR/ApiController.cs | 6 +- LightlessSync/WebAPI/SignalR/TokenProvider.cs | 4 +- 16 files changed, 132 insertions(+), 106 deletions(-) diff --git a/.gitignore b/.gitignore index dfcfd56..fecf7ea 100644 --- a/.gitignore +++ b/.gitignore @@ -348,3 +348,6 @@ MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ + +# idea +/.idea diff --git a/LightlessSync/FileCache/TransientResourceManager.cs b/LightlessSync/FileCache/TransientResourceManager.cs index 115c616..ed2cca6 100644 --- a/LightlessSync/FileCache/TransientResourceManager.cs +++ b/LightlessSync/FileCache/TransientResourceManager.cs @@ -78,7 +78,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase } } - private string PlayerPersistentDataKey => _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult() + "_" + _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult(); + private string PlayerPersistentDataKey => _dalamudUtil.GetPlayerName() + "_" + _dalamudUtil.GetHomeWorldId(); private ConcurrentDictionary> SemiTransientResources { get diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index a9be72b..cd57c34 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -52,7 +52,7 @@ public sealed class Plugin : IDalamudPlugin IFramework framework, IObjectTable objectTable, IClientState clientState, ICondition condition, IChatGui chatGui, IGameGui gameGui, IDtrBar dtrBar, IPluginLog pluginLog, ITargetManager targetManager, INotificationManager notificationManager, ITextureProvider textureProvider, IContextMenu contextMenu, IGameInteropProvider gameInteropProvider, IGameConfig gameConfig, - ISigScanner sigScanner, INamePlateGui namePlateGui, IAddonLifecycle addonLifecycle) + ISigScanner sigScanner, INamePlateGui namePlateGui, IAddonLifecycle addonLifecycle, IPlayerState playerState) { NativeDll.Initialize(pluginInterface.AssemblyLocation.DirectoryName); if (!Directory.Exists(pluginInterface.ConfigDirectory.FullName)) @@ -216,6 +216,7 @@ public sealed class Plugin : IDalamudPlugin gameData, targetManager, gameConfig, + playerState, sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), diff --git a/LightlessSync/Services/CharaData/CharaDataGposeTogetherManager.cs b/LightlessSync/Services/CharaData/CharaDataGposeTogetherManager.cs index d7463b2..cb5b0aa 100644 --- a/LightlessSync/Services/CharaData/CharaDataGposeTogetherManager.cs +++ b/LightlessSync/Services/CharaData/CharaDataGposeTogetherManager.cs @@ -450,7 +450,7 @@ public class CharaDataGposeTogetherManager : DisposableMediatorSubscriberBase }; } - var loc = await _dalamudUtil.GetMapDataAsync().ConfigureAwait(false); + var loc = _dalamudUtil.GetMapData(); worldData.LocationInfo = loc; if (_forceResendWorldData || worldData != _lastWorldData) diff --git a/LightlessSync/Services/CharaData/CharaDataManager.cs b/LightlessSync/Services/CharaData/CharaDataManager.cs index d8b2387..4a30639 100644 --- a/LightlessSync/Services/CharaData/CharaDataManager.cs +++ b/LightlessSync/Services/CharaData/CharaDataManager.cs @@ -254,7 +254,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase Logger.LogTrace("Attaching World data {data}", worldData); - worldData.LocationInfo = await _dalamudUtilService.GetMapDataAsync().ConfigureAwait(false); + worldData.LocationInfo = _dalamudUtilService.GetMapData(); Logger.LogTrace("World data serialized: {data}", worldData); diff --git a/LightlessSync/Services/CharaData/CharaDataNearbyManager.cs b/LightlessSync/Services/CharaData/CharaDataNearbyManager.cs index b3d4800..c66588d 100644 --- a/LightlessSync/Services/CharaData/CharaDataNearbyManager.cs +++ b/LightlessSync/Services/CharaData/CharaDataNearbyManager.cs @@ -186,8 +186,8 @@ public sealed class CharaDataNearbyManager : DisposableMediatorSubscriberBase var previousPoses = _nearbyData.Keys.ToList(); _nearbyData.Clear(); - var ownLocation = await _dalamudUtilService.RunOnFrameworkThread(() => _dalamudUtilService.GetMapData()).ConfigureAwait(false); - var player = await _dalamudUtilService.RunOnFrameworkThread(() => _dalamudUtilService.GetPlayerCharacter()).ConfigureAwait(false); + var ownLocation = _dalamudUtilService.GetMapData(); + var player = await _dalamudUtilService.GetPlayerCharacterAsync().ConfigureAwait(false); var currentServer = player.CurrentWorld; var playerPos = player.Position; diff --git a/LightlessSync/Services/Chat/ZoneChatService.cs b/LightlessSync/Services/Chat/ZoneChatService.cs index 38e76d9..5d37f2b 100644 --- a/LightlessSync/Services/Chat/ZoneChatService.cs +++ b/LightlessSync/Services/Chat/ZoneChatService.cs @@ -571,7 +571,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS try { - var location = await _dalamudUtilService.GetMapDataAsync().ConfigureAwait(false); + var location = _dalamudUtilService.GetMapData(); var territoryId = (ushort)location.TerritoryId; var worldId = (ushort)location.ServerId; @@ -697,7 +697,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS { try { - var worldId = (ushort)await _dalamudUtilService.GetWorldIdAsync().ConfigureAwait(false); + var worldId = (ushort)_dalamudUtilService.GetWorldId(); return definition.Descriptor with { WorldId = worldId }; } catch (Exception ex) @@ -1149,7 +1149,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS { try { - return _dalamudUtilService.GetPlayerNameAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + return _dalamudUtilService.GetPlayerName(); } catch (Exception ex) { diff --git a/LightlessSync/Services/ContextMenuService.cs b/LightlessSync/Services/ContextMenuService.cs index 7d35529..3fe893c 100644 --- a/LightlessSync/Services/ContextMenuService.cs +++ b/LightlessSync/Services/ContextMenuService.cs @@ -241,7 +241,7 @@ internal class ContextMenuService : IHostedService return; } - var senderCid = (await _dalamudUtil.GetCIDAsync().ConfigureAwait(false)).ToString().GetHash256(); + var senderCid = _dalamudUtil.GetCID().ToString().GetHash256(); var receiverCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(targetData.Address); _logger.LogInformation("Sending pair request: sender {SenderCid}, receiver {ReceiverCid}", senderCid, receiverCid); diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 91d0037..ef6fe7a 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -1,5 +1,4 @@ using Dalamud.Game.ClientState.Conditions; -using Dalamud.Game.ClientState.Objects; using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Objects.Types; using Dalamud.Plugin.Services; @@ -37,6 +36,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber private readonly ICondition _condition; private readonly IDataManager _gameData; private readonly IGameConfig _gameConfig; + private readonly IPlayerState _playerState; private readonly BlockedCharacterHandler _blockedCharacterHandler; private readonly IFramework _framework; private readonly IGameGui _gameGui; @@ -60,7 +60,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber private Lazy _cid; public DalamudUtilService(ILogger logger, IClientState clientState, IObjectTable objectTable, IFramework framework, - IGameGui gameGui, ICondition condition, IDataManager gameData, ITargetManager targetManager, IGameConfig gameConfig, + IGameGui gameGui, ICondition condition, IDataManager gameData, ITargetManager targetManager, IGameConfig gameConfig, IPlayerState playerState, ActorObjectService actorObjectService, BlockedCharacterHandler blockedCharacterHandler, LightlessMediator mediator, PerformanceCollectorService performanceCollector, LightlessConfigService configService, PlayerPerformanceConfigService playerPerformanceConfigService, Lazy pairFactory) { @@ -72,6 +72,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber _condition = condition; _gameData = gameData; _gameConfig = gameConfig; + _playerState = playerState; _actorObjectService = actorObjectService; _targetManager = targetManager; _blockedCharacterHandler = blockedCharacterHandler; @@ -95,6 +96,12 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber TerritoryData = new(() => BuildTerritoryData(clientLanguage)); TerritoryDataEnglish = new(() => BuildTerritoryData(Dalamud.Game.ClientLanguage.English)); MapData = new(() => BuildMapData(clientLanguage)); + ContentFinderData = new Lazy>(() => + { + return _gameData.GetExcelSheet()! + .Where(w => w.RowId != 0 && !string.IsNullOrEmpty(w.ContentFinderCondition.ValueNullable?.Name.ToString())) + .ToDictionary(w => w.RowId, w => w.ContentFinderCondition.Value.Name.ToString()); + }); mediator.Subscribe(this, (msg) => { if (clientState.IsPvP) return; @@ -279,6 +286,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber public Lazy> TerritoryData { get; private set; } public Lazy> TerritoryDataEnglish { get; private set; } public Lazy> MapData { get; private set; } + public Lazy> ContentFinderData { get; private set; } public bool IsLodEnabled { get; private set; } public LightlessMediator Mediator { get; } @@ -372,7 +380,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber public bool GetIsPlayerPresent() { EnsureIsOnFramework(); - return _objectTable.LocalPlayer != null && _objectTable.LocalPlayer.IsValid(); + return _objectTable.LocalPlayer != null && _objectTable.LocalPlayer.IsValid() && _playerState.IsLoaded; } public async Task GetIsPlayerPresentAsync() @@ -517,34 +525,17 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber public string GetPlayerName() { - EnsureIsOnFramework(); - return _objectTable.LocalPlayer?.Name.ToString() ?? "--"; - } - - public async Task GetPlayerNameAsync() - { - return await RunOnFrameworkThread(GetPlayerName).ConfigureAwait(false); - } - - public async Task GetCIDAsync() - { - return await RunOnFrameworkThread(GetCID).ConfigureAwait(false); + return _playerState.CharacterName; } public unsafe ulong GetCID() { - EnsureIsOnFramework(); - var playerChar = GetPlayerCharacter(); - - if (playerChar == null || playerChar.Address == IntPtr.Zero) - return 0; - - return ((BattleChara*)playerChar.Address)->Character.ContentId; + return _playerState.ContentId; } - public async Task GetPlayerNameHashedAsync() + public string GetPlayerNameHashed() { - return await RunOnFrameworkThread(() => _cid.Value.ToString().GetHash256()).ConfigureAwait(false); + return _cid.Value.ToString().GetHash256(); } public static unsafe bool TryGetHashedCID(IPlayerCharacter? playerCharacter, out string hashedCid) @@ -583,54 +574,100 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber public uint GetHomeWorldId() { - EnsureIsOnFramework(); - return _objectTable.LocalPlayer?.HomeWorld.RowId ?? 0; + return _playerState.HomeWorld.RowId; } public uint GetWorldId() { - EnsureIsOnFramework(); - return _objectTable.LocalPlayer!.CurrentWorld.RowId; + return _playerState.CurrentWorld.RowId; } public unsafe LocationInfo GetMapData() { - EnsureIsOnFramework(); - var agentMap = AgentMap.Instance(); var houseMan = HousingManager.Instance(); - uint serverId = 0; - if (_objectTable.LocalPlayer == null) serverId = 0; - else serverId = _objectTable.LocalPlayer.CurrentWorld.RowId; - uint mapId = agentMap == null ? 0 : agentMap->CurrentMapId; - uint territoryId = agentMap == null ? 0 : agentMap->CurrentTerritoryId; - uint divisionId = houseMan == null ? 0 : (uint)(houseMan->GetCurrentDivision()); - uint wardId = houseMan == null ? 0 : (uint)(houseMan->GetCurrentWard() + 1); - uint houseId = 0; - var tempHouseId = houseMan == null ? 0 : (houseMan->GetCurrentPlot()); - if (!houseMan->IsInside()) tempHouseId = 0; - if (tempHouseId < -1) - { - divisionId = tempHouseId == -127 ? 2 : (uint)1; - tempHouseId = 100; - } - if (tempHouseId == -1) tempHouseId = 0; - houseId = (uint)tempHouseId; - if (houseId != 0) - { - territoryId = HousingManager.GetOriginalHouseTerritoryTypeId(); - } - uint roomId = houseMan == null ? 0 : (uint)(houseMan->GetCurrentRoom()); - return new LocationInfo() + var location = new LocationInfo(); + location.ServerId = _playerState.CurrentWorld.RowId; + //location.InstanceId = UIState.Instance()->PublicInstance.InstanceId; //TODO:Need API update first + location.TerritoryId = _clientState.TerritoryType; + location.MapId = _clientState.MapId; + if (houseMan != null) { - ServerId = serverId, - MapId = mapId, - TerritoryId = territoryId, - DivisionId = divisionId, - WardId = wardId, - HouseId = houseId, - RoomId = roomId - }; + if (houseMan->IsInside()) + { + location.TerritoryId = HousingManager.GetOriginalHouseTerritoryTypeId(); + var house = houseMan->GetCurrentIndoorHouseId(); + location.WardId = house.WardIndex + 1u; + location.HouseId = house.IsApartment ? 100 : house.PlotIndex + 1u; + location.RoomId = (uint)house.RoomNumber; + location.DivisionId = house.IsApartment ? house.ApartmentDivision + 1u : houseMan->GetCurrentDivision(); + } + else if (houseMan->IsInWorkshop()) + { + var workShop = houseMan->WorkshopTerritory; + var house = workShop->HouseId; + location.WardId = house.WardIndex + 1u; + location.HouseId = house.PlotIndex + 1u; + } + else if (houseMan->IsOutside()) + { + var outside = houseMan->OutdoorTerritory; + var house = outside->HouseId; + location.WardId = house.WardIndex + 1u; + location.HouseId = (uint)houseMan->GetCurrentPlot() + 1; + location.DivisionId = houseMan->GetCurrentDivision(); + } + //_logger.LogWarning(LocationToString(location)); + } + return location; + } + + public string LocationToString(LocationInfo location) + { + if (location.ServerId is 0 || location.TerritoryId is 0) return String.Empty; + var str = WorldData.Value[(ushort)location.ServerId]; + + if (ContentFinderData.Value.TryGetValue(location.TerritoryId , out var dutyName)) + { + str += $" - [In Duty]{dutyName}"; + } + else + { + if (location.HouseId is not 0 || location.MapId is 0) // Dont show mapName when in house/no map available + { + str += $" - {TerritoryData.Value[(ushort)location.TerritoryId]}"; + } + else + { + str += $" - {MapData.Value[(ushort)location.MapId].MapName}"; + } + + // if (location.InstanceId is not 0) + // { + // str += ((SeIconChar)(57520 + location.InstanceId)).ToIconString(); + // } + + if (location.WardId is not 0) + { + str += $" Ward #{location.WardId}"; + } + + if (location.HouseId is not 0 and not 100) + { + str += $" House #{location.HouseId}"; + } + else if (location.HouseId is 100) + { + str += $" {(location.DivisionId == 2 ? "[Subdivision]" : "")} Apartment"; + } + + if (location.RoomId is not 0) + { + str += $" Room #{location.RoomId}"; + } + } + + return str; } public unsafe void SetMarkerAndOpenMap(Vector3 position, Map map) @@ -642,21 +679,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber agentMap->SetFlagMapMarker(map.TerritoryType.RowId, map.RowId, position); } - public async Task GetMapDataAsync() - { - return await RunOnFrameworkThread(GetMapData).ConfigureAwait(false); - } - - public async Task GetWorldIdAsync() - { - return await RunOnFrameworkThread(GetWorldId).ConfigureAwait(false); - } - - public async Task GetHomeWorldIdAsync() - { - return await RunOnFrameworkThread(GetHomeWorldId).ConfigureAwait(false); - } - public unsafe bool IsGameObjectPresent(IntPtr key) { return _objectTable.Any(f => f.Address == key); diff --git a/LightlessSync/Services/LightFinder/LightFinderService.cs b/LightlessSync/Services/LightFinder/LightFinderService.cs index 82a51c7..f07064c 100644 --- a/LightlessSync/Services/LightFinder/LightFinderService.cs +++ b/LightlessSync/Services/LightFinder/LightFinderService.cs @@ -67,7 +67,7 @@ public class LightFinderService : IHostedService, IMediatorSubscriber { try { - var cid = await _dalamudUtil.GetCIDAsync().ConfigureAwait(false); + var cid = _dalamudUtil.GetCID(); return cid.ToString().GetHash256(); } catch (Exception ex) diff --git a/LightlessSync/Services/ServerConfiguration/ServerConfigurationManager.cs b/LightlessSync/Services/ServerConfiguration/ServerConfigurationManager.cs index 5cb3e15..339f462 100644 --- a/LightlessSync/Services/ServerConfiguration/ServerConfigurationManager.cs +++ b/LightlessSync/Services/ServerConfiguration/ServerConfigurationManager.cs @@ -101,9 +101,9 @@ public class ServerConfigurationManager } hasMulti = false; - var charaName = _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult(); - var worldId = _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult(); - var cid = _dalamudUtil.GetCIDAsync().GetAwaiter().GetResult(); + var charaName = _dalamudUtil.GetPlayerName(); + var worldId = _dalamudUtil.GetHomeWorldId(); + var cid = _dalamudUtil.GetCID(); var auth = currentServer.Authentications.FindAll(f => string.Equals(f.CharacterName, charaName) && f.WorldId == worldId); if (auth.Count >= 2) @@ -148,9 +148,9 @@ public class ServerConfigurationManager } hasMulti = false; - var charaName = _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult(); - var worldId = _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult(); - var cid = _dalamudUtil.GetCIDAsync().GetAwaiter().GetResult(); + var charaName = _dalamudUtil.GetPlayerName(); + var worldId = _dalamudUtil.GetHomeWorldId(); + var cid = _dalamudUtil.GetCID(); if (!currentServer.Authentications.Any() && currentServer.SecretKeys.Any()) { currentServer.Authentications.Add(new Authentication() @@ -268,16 +268,16 @@ public class ServerConfigurationManager { if (serverSelectionIndex == -1) serverSelectionIndex = CurrentServerIndex; var server = GetServerByIndex(serverSelectionIndex); - if (server.Authentications.Exists(c => string.Equals(c.CharacterName, _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult(), StringComparison.Ordinal) - && c.WorldId == _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult())) + if (server.Authentications.Exists(c => string.Equals(c.CharacterName, _dalamudUtil.GetPlayerName(), StringComparison.Ordinal) + && c.WorldId == _dalamudUtil.GetHomeWorldId())) return; server.Authentications.Add(new Authentication() { - CharacterName = _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult(), - WorldId = _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult(), + CharacterName = _dalamudUtil.GetPlayerName(), + WorldId = _dalamudUtil.GetHomeWorldId(), SecretKeyIdx = !server.UseOAuth2 ? server.SecretKeys.Last().Key : -1, - LastSeenCID = _dalamudUtil.GetCIDAsync().GetAwaiter().GetResult() + LastSeenCID = _dalamudUtil.GetCID() }); Save(); } diff --git a/LightlessSync/UI/DtrEntry.cs b/LightlessSync/UI/DtrEntry.cs index 9cadb4c..71eddfb 100644 --- a/LightlessSync/UI/DtrEntry.cs +++ b/LightlessSync/UI/DtrEntry.cs @@ -344,7 +344,7 @@ public sealed class DtrEntry : IDisposable, IHostedService try { - var cid = _dalamudUtilService.GetCIDAsync().GetAwaiter().GetResult(); + var cid = _dalamudUtilService.GetCID(); var hashedCid = cid.ToString().GetHash256(); _localHashedCid = hashedCid; _localHashedCidFetchedAt = now; diff --git a/LightlessSync/UI/TopTabMenu.cs b/LightlessSync/UI/TopTabMenu.cs index 92215fe..1297fa6 100644 --- a/LightlessSync/UI/TopTabMenu.cs +++ b/LightlessSync/UI/TopTabMenu.cs @@ -440,7 +440,7 @@ public class TopTabMenu try { - var myCidHash = (await _dalamudUtilService.GetCIDAsync().ConfigureAwait(false)).ToString().GetHash256(); + var myCidHash = _dalamudUtilService.GetCID().ToString().GetHash256(); await _apiController.TryPairWithContentId(request.HashedCid).ConfigureAwait(false); _pairRequestService.RemoveRequest(request.HashedCid); diff --git a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs index 2f317b9..fdc4719 100644 --- a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs +++ b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs @@ -193,7 +193,7 @@ public partial class ApiController CensusDataDto? censusDto = null; if (_serverManager.SendCensusData && _lastCensus != null) { - var world = await _dalamudUtil.GetWorldIdAsync().ConfigureAwait(false); + var world = _dalamudUtil.GetWorldId(); censusDto = new((ushort)world, _lastCensus.RaceId, _lastCensus.TribeId, _lastCensus.Gender); Logger.LogDebug("Attaching Census Data: {data}", censusDto); } diff --git a/LightlessSync/WebAPI/SignalR/ApiController.cs b/LightlessSync/WebAPI/SignalR/ApiController.cs index 14c90ca..9639f6f 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.cs @@ -544,8 +544,8 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL private void DalamudUtilOnLogIn() { - var charaName = _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult(); - var worldId = _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult(); + var charaName = _dalamudUtil.GetPlayerName(); + var worldId = _dalamudUtil.GetHomeWorldId(); var auth = _serverManager.CurrentServer.Authentications.Find(f => string.Equals(f.CharacterName, charaName, StringComparison.Ordinal) && f.WorldId == worldId); if (auth?.AutoLogin ?? false) { @@ -653,7 +653,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL CensusDataDto? dto = null; if (_serverManager.SendCensusData && _lastCensus != null) { - var world = await _dalamudUtil.GetWorldIdAsync().ConfigureAwait(false); + var world = _dalamudUtil.GetWorldId(); dto = new((ushort)world, _lastCensus.RaceId, _lastCensus.TribeId, _lastCensus.Gender); Logger.LogDebug("Attaching Census Data: {data}", dto); } diff --git a/LightlessSync/WebAPI/SignalR/TokenProvider.cs b/LightlessSync/WebAPI/SignalR/TokenProvider.cs index fa23363..2446986 100644 --- a/LightlessSync/WebAPI/SignalR/TokenProvider.cs +++ b/LightlessSync/WebAPI/SignalR/TokenProvider.cs @@ -72,7 +72,7 @@ public sealed class TokenProvider : IDisposable, IMediatorSubscriber result = await _httpClient.PostAsync(tokenUri, new FormUrlEncodedContent( [ new KeyValuePair("auth", auth), - new KeyValuePair("charaIdent", await _dalamudUtil.GetPlayerNameHashedAsync().ConfigureAwait(false)), + new KeyValuePair("charaIdent", _dalamudUtil.GetPlayerNameHashed()), ]), ct).ConfigureAwait(false); } else @@ -152,7 +152,7 @@ public sealed class TokenProvider : IDisposable, IMediatorSubscriber JwtIdentifier jwtIdentifier; try { - var playerIdentifier = await _dalamudUtil.GetPlayerNameHashedAsync().ConfigureAwait(false); + var playerIdentifier = _dalamudUtil.GetPlayerNameHashed(); if (string.IsNullOrEmpty(playerIdentifier)) { -- 2.49.1 From 70745613e13cb27dd7e90d30a811bd8b1f28d965 Mon Sep 17 00:00:00 2001 From: Tsubasahane Date: Sat, 27 Dec 2025 19:57:21 +0800 Subject: [PATCH 10/87] Location Sharing --- LightlessAPI | 2 +- LightlessSync/Plugin.cs | 1 + LightlessSync/Services/DalamudUtilService.cs | 13 ++-- .../Services/LocationShareService.cs | 78 +++++++++++++++++++ LightlessSync/Services/Mediator/Messages.cs | 2 + .../UI/Components/DrawFolderGroup.cs | 8 ++ LightlessSync/UI/Components/DrawUserPair.cs | 75 ++++++++++++++++++ LightlessSync/UI/DrawEntityFactory.cs | 4 + LightlessSync/UI/PermissionWindowUI.cs | 20 +++++ .../SignalR/ApIController.Functions.Users.cs | 12 +++ .../ApiController.Functions.Callbacks.cs | 13 ++++ LightlessSync/WebAPI/SignalR/ApiController.cs | 2 + 12 files changed, 224 insertions(+), 6 deletions(-) create mode 100644 LightlessSync/Services/LocationShareService.cs diff --git a/LightlessAPI b/LightlessAPI index 5656600..fdd492a 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit 56566003e0e93bba05dcef49fd3ce23c6a204d81 +Subproject commit fdd492a8f478949d910ed0efd3e4a3ca3312ed9c diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index cd57c34..4131e40 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -137,6 +137,7 @@ public sealed class Plugin : IDalamudPlugin services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => new TextureMetadataHelper(sp.GetRequiredService>(), gameData)); diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index ef6fe7a..36ce98b 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -1,11 +1,13 @@ using Dalamud.Game.ClientState.Conditions; using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Game.Text; using Dalamud.Plugin.Services; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.Game.Character; using FFXIVClientStructs.FFXIV.Client.Game.Control; +using FFXIVClientStructs.FFXIV.Client.Game.UI; using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; using FFXIVClientStructs.FFXIV.Client.UI.Agent; using LightlessSync.API.Dto.CharaData; @@ -25,6 +27,7 @@ using System.Runtime.CompilerServices; using System.Text; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject; +using Map = Lumina.Excel.Sheets.Map; using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags; namespace LightlessSync.Services; @@ -588,7 +591,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber var location = new LocationInfo(); location.ServerId = _playerState.CurrentWorld.RowId; - //location.InstanceId = UIState.Instance()->PublicInstance.InstanceId; //TODO:Need API update first + location.InstanceId = UIState.Instance()->PublicInstance.InstanceId; location.TerritoryId = _clientState.TerritoryType; location.MapId = _clientState.MapId; if (houseMan != null) @@ -642,10 +645,10 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber str += $" - {MapData.Value[(ushort)location.MapId].MapName}"; } - // if (location.InstanceId is not 0) - // { - // str += ((SeIconChar)(57520 + location.InstanceId)).ToIconString(); - // } + if (location.InstanceId is not 0) + { + str += ((SeIconChar)(57520 + location.InstanceId)).ToIconString(); + } if (location.WardId is not 0) { diff --git a/LightlessSync/Services/LocationShareService.cs b/LightlessSync/Services/LocationShareService.cs new file mode 100644 index 0000000..0d3f5dc --- /dev/null +++ b/LightlessSync/Services/LocationShareService.cs @@ -0,0 +1,78 @@ +using LightlessSync.API.Data; +using LightlessSync.API.Dto.CharaData; +using LightlessSync.API.Dto.User; +using LightlessSync.Services.Mediator; +using LightlessSync.WebAPI; +using Microsoft.Extensions.Logging; + +namespace LightlessSync.Services +{ + public class LocationShareService : DisposableMediatorSubscriberBase + { + private readonly DalamudUtilService _dalamudUtilService; + private readonly ApiController _apiController; + private Dictionary _locations = []; + + public LocationShareService(ILogger logger, LightlessMediator mediator, DalamudUtilService dalamudUtilService, ApiController apiController) : base(logger, mediator) + { + _dalamudUtilService = dalamudUtilService; + _apiController = apiController; + + + Mediator.Subscribe(this, (msg) => _locations.Clear()); + Mediator.Subscribe(this, (msg) => + { + _ = _apiController.UpdateLocation(new LocationDto(new UserData(_apiController.UID, apiController.DisplayName), _dalamudUtilService.GetMapData())); + _ = RequestAllLocation(); + } ); + Mediator.Subscribe(this, UpdateLocationList); + Mediator.Subscribe(this, + msg => _ = _apiController.UpdateLocation(new LocationDto(new UserData(_apiController.UID, _apiController.DisplayName), _dalamudUtilService.GetMapData()))); + } + + private void UpdateLocationList(LocationMessage msg) + { + if (_locations.ContainsKey(msg.Uid) && msg.LocationInfo.ServerId is 0) + { + _locations.Remove(msg.Uid); + return; + } + + if ( msg.LocationInfo.ServerId is not 0 && !_locations.TryAdd(msg.Uid, msg.LocationInfo)) + { + _locations[msg.Uid] = msg.LocationInfo; + } + } + + private async Task RequestAllLocation() + { + try + { + var data = await _apiController.RequestAllLocationInfo().ConfigureAwait(false); + _locations = data.ToDictionary(x => x.user.UID, x => x.location, StringComparer.Ordinal); + } + catch (Exception e) + { + Logger.LogError(e,"RequestAllLocation error : "); + throw; + } + } + + public string GetUserLocation(string uid) + { + try + { + if (_locations.TryGetValue(uid, out var location)) + { + return _dalamudUtilService.LocationToString(location); + } + return String.Empty; + } + catch (Exception e) + { + Logger.LogError(e,"GetUserLocation error : "); + throw; + } + } + } +} \ No newline at end of file diff --git a/LightlessSync/Services/Mediator/Messages.cs b/LightlessSync/Services/Mediator/Messages.cs index 758b9f5..be0c06b 100644 --- a/LightlessSync/Services/Mediator/Messages.cs +++ b/LightlessSync/Services/Mediator/Messages.cs @@ -135,5 +135,7 @@ public record ChatChannelsUpdated : MessageBase; public record ChatChannelMessageAdded(string ChannelKey, ChatMessageEntry Message) : MessageBase; public record GroupCollectionChangedMessage : MessageBase; public record OpenUserProfileMessage(UserData User) : MessageBase; +public record LocationMessage(string Uid, LocationInfo LocationInfo) : MessageBase; + #pragma warning restore S2094 #pragma warning restore MA0048 // File name must match type name \ No newline at end of file diff --git a/LightlessSync/UI/Components/DrawFolderGroup.cs b/LightlessSync/UI/Components/DrawFolderGroup.cs index c39326c..277115a 100644 --- a/LightlessSync/UI/Components/DrawFolderGroup.cs +++ b/LightlessSync/UI/Components/DrawFolderGroup.cs @@ -131,6 +131,7 @@ public class DrawFolderGroup : DrawFolderBase bool disableSounds = perm.IsDisableSounds(); bool disableAnims = perm.IsDisableAnimations(); bool disableVfx = perm.IsDisableVFX(); + bool shareLocation = perm.IsSharingLocation(); if ((_groupFullInfoDto.GroupPermissions.IsPreferDisableAnimations() != disableAnims || _groupFullInfoDto.GroupPermissions.IsPreferDisableSounds() != disableSounds @@ -164,6 +165,13 @@ public class DrawFolderGroup : DrawFolderBase _ = _apiController.GroupChangeIndividualPermissionState(new(_groupFullInfoDto.Group, new(_apiController.UID), perm)); ImGui.CloseCurrentPopup(); } + + if (_uiSharedService.IconTextButton(!shareLocation ? FontAwesomeIcon.Globe : FontAwesomeIcon.StopCircle, !shareLocation ? "Share your location to all users in Syncshell" : "STOP Share your location to all users in Syncshell", menuWidth, true)) + { + perm.SetShareLocation(!shareLocation); + _ = _apiController.GroupChangeIndividualPermissionState(new(_groupFullInfoDto.Group, new(_apiController.UID), perm)); + ImGui.CloseCurrentPopup(); + } if (IsModerator || IsOwner) { diff --git a/LightlessSync/UI/Components/DrawUserPair.cs b/LightlessSync/UI/Components/DrawUserPair.cs index 8e03ae4..5f3d300 100644 --- a/LightlessSync/UI/Components/DrawUserPair.cs +++ b/LightlessSync/UI/Components/DrawUserPair.cs @@ -37,6 +37,7 @@ public class DrawUserPair private readonly UiSharedService _uiSharedService; private readonly PlayerPerformanceConfigService _performanceConfigService; private readonly LightlessConfigService _configService; + private readonly LocationShareService _locationShareService; private readonly CharaDataManager _charaDataManager; private readonly PairLedger _pairLedger; private float _menuWidth = -1; @@ -57,6 +58,7 @@ public class DrawUserPair UiSharedService uiSharedService, PlayerPerformanceConfigService performanceConfigService, LightlessConfigService configService, + LocationShareService locationShareService, CharaDataManager charaDataManager, PairLedger pairLedger) { @@ -74,6 +76,7 @@ public class DrawUserPair _uiSharedService = uiSharedService; _performanceConfigService = performanceConfigService; _configService = configService; + _locationShareService = locationShareService; _charaDataManager = charaDataManager; _pairLedger = pairLedger; } @@ -216,6 +219,17 @@ public class DrawUserPair _ = _apiController.UserSetPairPermissions(new UserPermissionsDto(_pair.UserData, permissions)); } UiSharedService.AttachToolTip("Changes VFX sync permissions with this user." + (individual ? individualText : string.Empty)); + + var isShareingLocation = _pair.UserPair!.OwnPermissions.IsSharingLocation(); + string isShareingLocationText = isShareingLocation ? "Disable location sharing" : "Enable location sharing"; + var isShareingLocationIcon = isShareingLocation ? FontAwesomeIcon.StopCircle : FontAwesomeIcon.Globe; + if (_uiSharedService.IconTextButton(isShareingLocationIcon, isShareingLocationText, _menuWidth, true)) + { + var permissions = _pair.UserPair.OwnPermissions; + permissions.SetShareLocation(!isShareingLocation); + _ = _apiController.UserSetPairPermissions(new UserPermissionsDto(_pair.UserData, permissions)); + } + UiSharedService.AttachToolTip("Changes location sharing permissions with this user." + (individual ? individualText : string.Empty)); } private void DrawIndividualMenu() @@ -567,6 +581,7 @@ public class DrawUserPair : UiSharedService.TooltipSeparator + "Hold CTRL to enable preferred permissions while pausing." + Environment.NewLine + "This will leave this pair paused even if unpausing syncshells including this pair.")) : "Resume pairing with " + _pair.UserData.AliasOrUID); + //Location sharing if (_pair.IsPaired) { var individualSoundsDisabled = (_pair.UserPair?.OwnPermissions.IsDisableSounds() ?? false) || (_pair.UserPair?.OtherPermissions.IsDisableSounds() ?? false); @@ -574,6 +589,66 @@ public class DrawUserPair var individualVFXDisabled = (_pair.UserPair?.OwnPermissions.IsDisableVFX() ?? false) || (_pair.UserPair?.OtherPermissions.IsDisableVFX() ?? false); var individualIsSticky = _pair.UserPair!.OwnPermissions.IsSticky(); var individualIcon = individualIsSticky ? FontAwesomeIcon.ArrowCircleUp : FontAwesomeIcon.InfoCircle; + + + var shareLocationIcon = FontAwesomeIcon.Globe; + var shareLocation = _pair.UserPair?.OwnPermissions.IsSharingLocation() ?? false; + var shareLocationOther = _pair.UserPair?.OtherPermissions.IsSharingLocation() ?? false; + var shareColor = shareLocation switch + { + true when shareLocationOther => UIColors.Get("LightlessGreen"), + false when shareLocationOther => UIColors.Get("LightlessBlue"), + _ => UIColors.Get("LightlessYellow"), + }; + + if (shareLocation || shareLocationOther) + { + currentRightSide -= (_uiSharedService.GetIconSize(shareLocationIcon).X + spacingX); + ImGui.SameLine(currentRightSide); + using (ImRaii.PushColor(ImGuiCol.Text, shareColor, shareLocation || shareLocationOther)) + _uiSharedService.IconText(shareLocationIcon); + + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + if (shareLocationOther) + { + var location = _locationShareService.GetUserLocation(_pair.UserPair!.User.UID); + if (_pair.IsOnline) + { + if (!string.IsNullOrEmpty(location)) + { + _uiSharedService.IconText(FontAwesomeIcon.LocationArrow); + ImGui.SameLine(); + ImGui.TextUnformatted(location); + } + else + { + ImGui.TextUnformatted("Location info not updated, reconnect or waiting for zone-changing."); + } + } + else + { + ImGui.TextUnformatted("User not onlineㄟ( ▔, ▔ )ㄏ"); + } + } + else + { + ImGui.TextUnformatted("NOT Sharing location with you.(⊙x⊙;)"); + } + ImGui.Separator(); + + if (shareLocation) + { + ImGui.TextUnformatted("Sharing your location.ヾ(•ω•`)o"); + } + else + { + ImGui.TextUnformatted("NOT sharing your location.(´。_。`)"); + } + ImGui.EndTooltip(); + } + } if (individualAnimDisabled || individualSoundsDisabled || individualVFXDisabled || individualIsSticky) { diff --git a/LightlessSync/UI/DrawEntityFactory.cs b/LightlessSync/UI/DrawEntityFactory.cs index 3c71f5c..e810a29 100644 --- a/LightlessSync/UI/DrawEntityFactory.cs +++ b/LightlessSync/UI/DrawEntityFactory.cs @@ -28,6 +28,7 @@ public class DrawEntityFactory private readonly ServerConfigurationManager _serverConfigurationManager; private readonly LightlessConfigService _configService; private readonly UiSharedService _uiSharedService; + private readonly LocationShareService _locationShareService; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; private readonly CharaDataManager _charaDataManager; private readonly SelectTagForPairUi _selectTagForPairUi; @@ -52,6 +53,7 @@ public class DrawEntityFactory ServerConfigurationManager serverConfigurationManager, LightlessConfigService configService, UiSharedService uiSharedService, + LocationShareService locationShareService, PlayerPerformanceConfigService playerPerformanceConfigService, CharaDataManager charaDataManager, SelectTagForSyncshellUi selectTagForSyncshellUi, @@ -71,6 +73,7 @@ public class DrawEntityFactory _serverConfigurationManager = serverConfigurationManager; _configService = configService; _uiSharedService = uiSharedService; + _locationShareService = locationShareService; _playerPerformanceConfigService = playerPerformanceConfigService; _charaDataManager = charaDataManager; _selectTagForSyncshellUi = selectTagForSyncshellUi; @@ -162,6 +165,7 @@ public class DrawEntityFactory _uiSharedService, _playerPerformanceConfigService, _configService, + _locationShareService, _charaDataManager, _pairLedger); } diff --git a/LightlessSync/UI/PermissionWindowUI.cs b/LightlessSync/UI/PermissionWindowUI.cs index 5dee098..9d88547 100644 --- a/LightlessSync/UI/PermissionWindowUI.cs +++ b/LightlessSync/UI/PermissionWindowUI.cs @@ -43,6 +43,7 @@ public class PermissionWindowUI : WindowMediatorSubscriberBase var disableSounds = _ownPermissions.IsDisableSounds(); var disableAnimations = _ownPermissions.IsDisableAnimations(); var disableVfx = _ownPermissions.IsDisableVFX(); + var shareLocation = _ownPermissions.IsSharingLocation(); var style = ImGui.GetStyle(); var indentSize = ImGui.GetFrameHeight() + style.ItemSpacing.X; @@ -70,6 +71,7 @@ public class PermissionWindowUI : WindowMediatorSubscriberBase var otherDisableSounds = otherPerms.IsDisableSounds(); var otherDisableAnimations = otherPerms.IsDisableAnimations(); var otherDisableVFX = otherPerms.IsDisableVFX(); + var otherShareLocation = otherPerms.IsSharingLocation(); using (ImRaii.PushIndent(indentSize, false)) { @@ -124,6 +126,24 @@ public class PermissionWindowUI : WindowMediatorSubscriberBase ImGui.AlignTextToFramePadding(); ImGui.Text(Pair.UserData.AliasOrUID + " has " + (!otherDisableVFX ? "not " : string.Empty) + "disabled VFX sync with you"); } + + if (ImGui.Checkbox("Enable location Sharing", ref shareLocation)) + { + _ownPermissions.SetShareLocation(shareLocation); + } + _uiSharedService.DrawHelpText("Enable location sharing will only effect your side." + UiSharedService.TooltipSeparator + + "Note: this is NOT bidirectional, you can choose to share even others dont share with you."); + using (ImRaii.PushIndent(indentSize, false)) + { + _uiSharedService.BooleanToColoredIcon(shareLocation, false); + ImGui.SameLine(); + ImGui.AlignTextToFramePadding(); + ImGui.Text((!shareLocation ? "Not" : string.Empty) + "sharing location with " + Pair.UserData.AliasOrUID + " ."); + +#if DEBUG + _uiSharedService.BooleanToColoredIcon(otherShareLocation, true); +#endif + } ImGuiHelpers.ScaledDummy(0.5f); ImGui.Separator(); diff --git a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs index fdc4719..6cd704f 100644 --- a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs +++ b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs @@ -200,5 +200,17 @@ public partial class ApiController await UserPushData(new(visibleCharacters, character, censusDto)).ConfigureAwait(false); } + + public async Task UpdateLocation(LocationDto locationDto, bool offline = false) + { + if (!IsConnected) return; + await _lightlessHub!.SendAsync(nameof(UpdateLocation), locationDto, offline).ConfigureAwait(false); + } + + public async Task> RequestAllLocationInfo() + { + if (!IsConnected) return []; + return await _lightlessHub!.InvokeAsync>(nameof(RequestAllLocationInfo)).ConfigureAwait(false); + } } #pragma warning restore MA0040 \ No newline at end of file diff --git a/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs b/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs index 490800f..01abc88 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs @@ -259,6 +259,13 @@ public partial class ApiController ExecuteSafely(() => Mediator.Publish(new GPoseLobbyReceiveWorldData(userData, worldData))); return Task.CompletedTask; } + + public Task Client_SendLocationToClient(LocationDto locationDto) + { + Logger.LogDebug($"{nameof(Client_SendLocationToClient)}: {locationDto.user}"); + ExecuteSafely(() => Mediator.Publish(new LocationMessage(locationDto.user.UID, locationDto.location))); + return Task.CompletedTask; + } public void OnDownloadReady(Action act) { @@ -440,6 +447,12 @@ public partial class ApiController if (_initialized) return; _lightlessHub!.On(nameof(Client_GposeLobbyPushWorldData), act); } + + public void OnReciveLocation(Action act) + { + if (_initialized) return; + _lightlessHub!.On(nameof(Client_SendLocationToClient), act); + } private void ExecuteSafely(Action act) { diff --git a/LightlessSync/WebAPI/SignalR/ApiController.cs b/LightlessSync/WebAPI/SignalR/ApiController.cs index 9639f6f..a814758 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.cs @@ -606,6 +606,8 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL OnGposeLobbyPushCharacterData((dto) => _ = Client_GposeLobbyPushCharacterData(dto)); OnGposeLobbyPushPoseData((dto, data) => _ = Client_GposeLobbyPushPoseData(dto, data)); OnGposeLobbyPushWorldData((dto, data) => _ = Client_GposeLobbyPushWorldData(dto, data)); + OnReciveLocation(dto => _ = Client_SendLocationToClient(dto)); + _healthCheckTokenSource?.Cancel(); _healthCheckTokenSource?.Dispose(); -- 2.49.1 From 24fca31606a7ec16f4941ecfa6e83ccc53ea8c58 Mon Sep 17 00:00:00 2001 From: choco Date: Sat, 27 Dec 2025 23:09:29 +0100 Subject: [PATCH 11/87] join syncshell draw modal --- LightlessSync/UI/LightFinderUI.cs | 308 +++++++++++++++++++++++++----- 1 file changed, 258 insertions(+), 50 deletions(-) diff --git a/LightlessSync/UI/LightFinderUI.cs b/LightlessSync/UI/LightFinderUI.cs index ed55d4f..9cb7891 100644 --- a/LightlessSync/UI/LightFinderUI.cs +++ b/LightlessSync/UI/LightFinderUI.cs @@ -58,6 +58,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase private List _currentSyncshells = []; private GroupJoinDto? _joinDto; private GroupJoinInfoDto? _joinInfo; + private bool _joinModalOpen = true; private readonly List _nearbySyncshells = []; private DefaultPermissionsDto _ownPermissions = null!; private readonly HashSet _recentlyJoined = new(StringComparer.Ordinal); @@ -66,6 +67,11 @@ public class LightFinderUI : WindowMediatorSubscriberBase private LightfinderTab _selectedTab = LightfinderTab.NearbySyncshells; private string _userUid = string.Empty; + private const float AnimationSpeed = 6f; + private readonly Dictionary _itemAlpha = new(StringComparer.Ordinal); + private readonly HashSet _currentVisibleItems = new(StringComparer.Ordinal); + private readonly HashSet _previousVisibleItems = new(StringComparer.Ordinal); + private enum LightfinderTab { NearbySyncshells, NearbyPlayers, BroadcastSettings, Help } #if DEBUG @@ -106,14 +112,14 @@ public class LightFinderUI : WindowMediatorSubscriberBase _lightlessProfileManager = lightlessProfileManager; _actorObjectService = actorObjectService; - _animatedHeader.Height = 100f; + _animatedHeader.Height = 85f; _animatedHeader.EnableBottomGradient = true; - _animatedHeader.GradientHeight = 120f; + _animatedHeader.GradientHeight = 90f; _animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects; IsOpen = false; WindowBuilder.For(this) - .SetSizeConstraints(new Vector2(620, 85), new Vector2(700, 600)) + .SetSizeConstraints(new Vector2(620, 85), new Vector2(700, 2000)) .Apply(); Mediator.Subscribe(this, async _ => await RefreshNearbySyncshellsAsync().ConfigureAwait(false)); @@ -367,6 +373,52 @@ public class LightFinderUI : WindowMediatorSubscriberBase #endregion + #region Animation Helpers + + private void UpdateItemAnimations(IEnumerable visibleItemIds) + { + var deltaTime = ImGui.GetIO().DeltaTime; + + _previousVisibleItems.Clear(); + foreach (var id in _currentVisibleItems) + _previousVisibleItems.Add(id); + + _currentVisibleItems.Clear(); + foreach (var id in visibleItemIds) + _currentVisibleItems.Add(id); + + // Fade in new items + foreach (var id in _currentVisibleItems) + { + if (!_itemAlpha.ContainsKey(id)) + _itemAlpha[id] = 0f; + + _itemAlpha[id] = Math.Min(1f, _itemAlpha[id] + deltaTime * AnimationSpeed); + } + + // Fade out removed items + var toRemove = new List(); + foreach (var (id, alpha) in _itemAlpha) + { + if (!_currentVisibleItems.Contains(id)) + { + _itemAlpha[id] = Math.Max(0f, alpha - deltaTime * AnimationSpeed); + if (_itemAlpha[id] <= 0.01f) + toRemove.Add(id); + } + } + + foreach (var id in toRemove) + _itemAlpha.Remove(id); + } + + private float GetItemAlpha(string itemId) + { + return _itemAlpha.TryGetValue(itemId, out var alpha) ? alpha : 1f; + } + + #endregion + #region Nearby Syncshells Tab private void DrawNearbySyncshellsTab() @@ -400,10 +452,14 @@ public class LightFinderUI : WindowMediatorSubscriberBase var cardData = BuildSyncshellCardData(); if (cardData.Count == 0) { + UpdateItemAnimations([]); ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells found."); return; } + // Update animations for syncshell items + UpdateItemAnimations(cardData.Select(c => $"shell_{c.Shell.Group.GID}")); + if (_compactView) DrawSyncshellGrid(cardData); else @@ -499,7 +555,13 @@ public class LightFinderUI : WindowMediatorSubscriberBase private void DrawSyncshellListItem(GroupJoinDto shell, string broadcasterName, bool isOwnBroadcast) { + var itemId = $"shell_{shell.Group.GID}"; + var alpha = GetItemAlpha(itemId); + if (alpha <= 0.01f) + return; + ImGui.PushID(shell.Group.GID); + using var alphaStyle = ImRaii.PushStyle(ImGuiStyleVar.Alpha, alpha); float rowHeight = 74f * ImGuiHelpers.GlobalScale; ImGui.BeginChild($"ShellRow##{shell.Group.GID}", new Vector2(-1, rowHeight), border: true); @@ -572,7 +634,13 @@ public class LightFinderUI : WindowMediatorSubscriberBase private void DrawSyncshellCompactItem(GroupJoinDto shell, bool isOwnBroadcast) { + var itemId = $"shell_{shell.Group.GID}"; + var alpha = GetItemAlpha(itemId); + if (alpha <= 0.01f) + return; + ImGui.PushID(shell.Group.GID); + using var alphaStyle = ImRaii.PushStyle(ImGuiStyleVar.Alpha, alpha); float rowHeight = 36f * ImGuiHelpers.GlobalScale; ImGui.BeginChild($"ShellCompact##{shell.Group.GID}", new Vector2(-1, rowHeight), border: true); @@ -655,6 +723,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase { _joinDto = new GroupJoinDto(shell.Group, shell.Password, shell.GroupUserPreferredPermissions); _joinInfo = info; + _joinModalOpen = true; _ownPermissions = _apiController.DefaultPermissions.DeepClone()!; } } @@ -685,65 +754,188 @@ public class LightFinderUI : WindowMediatorSubscriberBase { if (_joinDto == null || _joinInfo == null) return; - ImGui.Separator(); - ImGui.TextUnformatted($"Join Syncshell: {_joinDto.Group.AliasOrGID} by {_joinInfo.OwnerAliasOrUID}"); - ImGuiHelpers.ScaledDummy(2f); - ImGui.TextUnformatted("Suggested Permissions:"); + var scale = ImGuiHelpers.GlobalScale; + + // if not already open + if (!ImGui.IsPopupOpen("JoinSyncshellModal")) + ImGui.OpenPopup("JoinSyncshellModal"); - DrawPermissionRow("Sounds", _joinInfo.GroupPermissions.IsPreferDisableSounds(), _ownPermissions.DisableGroupSounds, v => _ownPermissions.DisableGroupSounds = v); - DrawPermissionRow("Animations", _joinInfo.GroupPermissions.IsPreferDisableAnimations(), _ownPermissions.DisableGroupAnimations, v => _ownPermissions.DisableGroupAnimations = v); - DrawPermissionRow("VFX", _joinInfo.GroupPermissions.IsPreferDisableVFX(), _ownPermissions.DisableGroupVFX, v => _ownPermissions.DisableGroupVFX = v); - - ImGui.NewLine(); - - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Plus, $"Finalize and join {_joinDto.Group.AliasOrGID}")) + Vector2 windowPos = ImGui.GetWindowPos(); + Vector2 windowSize = ImGui.GetWindowSize(); + float modalWidth = Math.Min(420f * scale, windowSize.X - 40f * scale); + float modalHeight = 295f * scale; + ImGui.SetNextWindowPos(new Vector2( + windowPos.X + (windowSize.X - modalWidth) * 0.5f, + windowPos.Y + (windowSize.Y - modalHeight) * 0.5f + ), ImGuiCond.Always); + ImGui.SetNextWindowSize(new Vector2(modalWidth, modalHeight)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * ImGuiHelpers.GlobalScale); + ImGui.PushStyleColor(ImGuiCol.Border, Vector4.Zero); + + using ImRaii.Color modalBorder = ImRaii.PushColor(ImGuiCol.Border, UIColors.Get("LightlessPurple").WithAlpha(0.5f)); + using ImRaii.Style rounding = ImRaii.PushStyle(ImGuiStyleVar.WindowRounding, 8f * scale); + using ImRaii.Style borderSize = ImRaii.PushStyle(ImGuiStyleVar.WindowBorderSize, 2f * scale); + using ImRaii.Style padding = ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(16f * scale, 16f * scale)); + + ImGuiWindowFlags flags = ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoScrollbar; + if (ImGui.BeginPopupModal("JoinSyncshellModal", ref _joinModalOpen, flags)) { - var finalPermissions = GroupUserPreferredPermissions.NoneSet; - finalPermissions.SetDisableSounds(_ownPermissions.DisableGroupSounds); - finalPermissions.SetDisableAnimations(_ownPermissions.DisableGroupAnimations); - finalPermissions.SetDisableVFX(_ownPermissions.DisableGroupVFX); + float contentWidth = ImGui.GetContentRegionAvail().X; - _ = _apiController.GroupJoinFinalize(new GroupJoinDto(_joinDto.Group, _joinDto.Password, finalPermissions)); - _recentlyJoined.Add(_joinDto.Group.GID); + // Header + _uiSharedService.MediumText("Join Syncshell", UIColors.Get("LightlessPurple")); + ImGuiHelpers.ScaledDummy(2f); + ImGui.TextColored(ImGuiColors.DalamudGrey, $"{_joinDto.Group.AliasOrGID} by {_joinInfo.OwnerAliasOrUID}"); - _joinDto = null; - _joinInfo = null; - } + ImGuiHelpers.ScaledDummy(8f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault").WithAlpha(0.4f)); + ImGuiHelpers.ScaledDummy(8f); - ImGui.SameLine(); - if (ImGui.Button("Cancel")) - { - _joinDto = null; - _joinInfo = null; + // Permissions section + ImGui.TextColored(ImGuiColors.DalamudWhite, "Permissions"); + ImGuiHelpers.ScaledDummy(6f); + + DrawPermissionToggleRow("Sounds", FontAwesomeIcon.VolumeUp, + _joinInfo.GroupPermissions.IsPreferDisableSounds(), + _ownPermissions.DisableGroupSounds, + v => _ownPermissions.DisableGroupSounds = v, + contentWidth); + + DrawPermissionToggleRow("Animations", FontAwesomeIcon.Running, + _joinInfo.GroupPermissions.IsPreferDisableAnimations(), + _ownPermissions.DisableGroupAnimations, + v => _ownPermissions.DisableGroupAnimations = v, + contentWidth); + + DrawPermissionToggleRow("VFX", FontAwesomeIcon.Magic, + _joinInfo.GroupPermissions.IsPreferDisableVFX(), + _ownPermissions.DisableGroupVFX, + v => _ownPermissions.DisableGroupVFX = v, + contentWidth); + + ImGuiHelpers.ScaledDummy(12f); + + // Buttons + float buttonHeight = 32f * scale; + float buttonSpacing = 8f * scale; + float joinButtonWidth = (contentWidth - buttonSpacing) * 0.65f; + float cancelButtonWidth = (contentWidth - buttonSpacing) * 0.35f; + + using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 6f * scale)) + { + // Join button + using (ImRaii.PushColor(ImGuiCol.Button, UIColors.Get("LightlessGreen"))) + using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f))) + using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreen").WithAlpha(0.75f))) + { + if (ImGui.Button($"Join Syncshell##{_joinDto.Group.GID}", new Vector2(joinButtonWidth, buttonHeight))) + { + var finalPermissions = GroupUserPreferredPermissions.NoneSet; + finalPermissions.SetDisableSounds(_ownPermissions.DisableGroupSounds); + finalPermissions.SetDisableAnimations(_ownPermissions.DisableGroupAnimations); + finalPermissions.SetDisableVFX(_ownPermissions.DisableGroupVFX); + + _ = _apiController.GroupJoinFinalize(new GroupJoinDto(_joinDto.Group, _joinDto.Password, finalPermissions)); + _recentlyJoined.Add(_joinDto.Group.GID); + + _joinDto = null; + _joinInfo = null; + ImGui.CloseCurrentPopup(); + } + } + + ImGui.SameLine(0f, buttonSpacing); + + // Cancel button + using (ImRaii.PushColor(ImGuiCol.Button, new Vector4(0.3f, 0.3f, 0.3f, 1f))) + using (ImRaii.PushColor(ImGuiCol.ButtonHovered, new Vector4(0.4f, 0.4f, 0.4f, 1f))) + using (ImRaii.PushColor(ImGuiCol.ButtonActive, new Vector4(0.25f, 0.25f, 0.25f, 1f))) + { + if (ImGui.Button("Cancel", new Vector2(cancelButtonWidth, buttonHeight))) + { + _joinDto = null; + _joinInfo = null; + ImGui.CloseCurrentPopup(); + } + } + } + + // Handle modal close via the bool ref + if (!_joinModalOpen) + { + _joinDto = null; + _joinInfo = null; + } + + ImGui.EndPopup(); } } - private void DrawPermissionRow(string label, bool suggested, bool current, Action apply) + private void DrawPermissionToggleRow(string label, FontAwesomeIcon icon, bool suggested, bool current, Action apply, float contentWidth) { - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted($"- {label}"); - - ImGui.SameLine(120 * ImGuiHelpers.GlobalScale); - ImGui.Text("Current:"); - ImGui.SameLine(); - _uiSharedService.BooleanToColoredIcon(!current); - - ImGui.SameLine(240 * ImGuiHelpers.GlobalScale); - ImGui.Text("Suggested:"); - ImGui.SameLine(); - _uiSharedService.BooleanToColoredIcon(!suggested); - - ImGui.SameLine(380 * ImGuiHelpers.GlobalScale); - using var id = ImRaii.PushId(label); - if (current != suggested) + var scale = ImGuiHelpers.GlobalScale; + float rowHeight = 28f * scale; + bool isDifferent = current != suggested; + + using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 4f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 4f * scale)) + using (ImRaii.PushColor(ImGuiCol.ChildBg, new Vector4(0.18f, 0.15f, 0.22f, 0.6f))) { - if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowRight, "Apply")) - apply(suggested); - } + ImGui.BeginChild($"PermRow_{label}", new Vector2(contentWidth, rowHeight), false, ImGuiWindowFlags.NoScrollbar); + + float innerPadding = 8f * scale; + ImGui.SetCursorPos(new Vector2(innerPadding, (rowHeight - ImGui.GetTextLineHeight()) * 0.5f)); - ImGui.NewLine(); + // Icon and label + var enabledColor = UIColors.Get("LightlessGreen"); + var disabledColor = UIColors.Get("DimRed"); + var currentColor = !current ? enabledColor : disabledColor; + + _uiSharedService.IconText(icon, currentColor); + ImGui.SameLine(0f, 6f * scale); + ImGui.TextUnformatted(label); + + // Current status + ImGui.SameLine(); + float statusX = contentWidth * 0.38f; + ImGui.SetCursorPosX(statusX); + ImGui.TextColored(ImGuiColors.DalamudGrey, "Current:"); + ImGui.SameLine(0f, 4f * scale); + _uiSharedService.BooleanToColoredIcon(!current, false); + + // Suggested status + ImGui.SameLine(); + float suggestedX = contentWidth * 0.60f; + ImGui.SetCursorPosX(suggestedX); + ImGui.TextColored(ImGuiColors.DalamudGrey, "Suggested:"); + ImGui.SameLine(0f, 4f * scale); + _uiSharedService.BooleanToColoredIcon(!suggested, false); + + // Apply checkmark button if different + if (isDifferent) + { + ImGui.SameLine(); + float applyX = contentWidth - 26f * scale; + ImGui.SetCursorPosX(applyX); + ImGui.SetCursorPosY((rowHeight - ImGui.GetFrameHeight()) * 0.5f); + + using (ImRaii.PushColor(ImGuiCol.Button, UIColors.Get("LightlessGreen").WithAlpha(0.6f))) + using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen"))) + using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreenDefault"))) + { + if (_uiSharedService.IconButton(FontAwesomeIcon.Check)) + apply(suggested); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Apply suggested"); + } + + ImGui.EndChild(); + } + ImGui.Dummy(new Vector2(0, 2f * scale)); } + #endregion #region Nearby Players Tab @@ -785,10 +977,14 @@ public class LightFinderUI : WindowMediatorSubscriberBase var playerData = BuildNearbyPlayerData(activeBroadcasts); if (playerData.Count == 0) { + UpdateItemAnimations([]); ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby Lightfinder players found."); return; } + // Update animations for player items + UpdateItemAnimations(playerData.Select(p => $"player_{p.HashedCid}")); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8f); ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1f); @@ -848,7 +1044,13 @@ public class LightFinderUI : WindowMediatorSubscriberBase private void DrawNearbyPlayerRow(NearbyPlayerData data) { + var itemId = $"player_{data.HashedCid}"; + var alpha = GetItemAlpha(itemId); + if (alpha <= 0.01f) + return; + ImGui.PushID(data.HashedCid); + using var alphaStyle = ImRaii.PushStyle(ImGuiStyleVar.Alpha, alpha); float rowHeight = 74f * ImGuiHelpers.GlobalScale; ImGui.BeginChild($"PlayerRow##{data.HashedCid}", new Vector2(-1, rowHeight), border: true); @@ -903,7 +1105,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase ImGui.SetCursorPosX(startX); _uiSharedService.IconText(FontAwesomeIcon.Wifi, UIColors.Get("LightlessBlue")); ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale); - ImGui.TextColored(ImGuiColors.DalamudGrey, "Lightfinder User"); + ImGui.TextColored(ImGuiColors.DalamudGrey, "Lightfinder sir ma'am or whatever"); } ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); @@ -916,7 +1118,13 @@ public class LightFinderUI : WindowMediatorSubscriberBase private void DrawNearbyPlayerCompactRow(NearbyPlayerData data) { + var itemId = $"player_{data.HashedCid}"; + var alpha = GetItemAlpha(itemId); + if (alpha <= 0.01f) + return; + ImGui.PushID(data.HashedCid); + using var alphaStyle = ImRaii.PushStyle(ImGuiStyleVar.Alpha, alpha); float rowHeight = 36f * ImGuiHelpers.GlobalScale; ImGui.BeginChild($"PlayerCompact##{data.HashedCid}", new Vector2(-1, rowHeight), border: true); -- 2.49.1 From eeda5aeb66943d3075c04882dd35ec5e4bb3992b Mon Sep 17 00:00:00 2001 From: Tsubasahane Date: Sun, 28 Dec 2025 10:54:01 +0800 Subject: [PATCH 12/87] Revert "Location Sharing" This reverts commit 70745613e13cb27dd7e90d30a811bd8b1f28d965. --- LightlessAPI | 2 +- LightlessSync/Plugin.cs | 1 - LightlessSync/Services/DalamudUtilService.cs | 13 ++-- .../Services/LocationShareService.cs | 78 ------------------- LightlessSync/Services/Mediator/Messages.cs | 2 - .../UI/Components/DrawFolderGroup.cs | 8 -- LightlessSync/UI/Components/DrawUserPair.cs | 75 ------------------ LightlessSync/UI/DrawEntityFactory.cs | 4 - LightlessSync/UI/PermissionWindowUI.cs | 20 ----- .../SignalR/ApIController.Functions.Users.cs | 12 --- .../ApiController.Functions.Callbacks.cs | 13 ---- LightlessSync/WebAPI/SignalR/ApiController.cs | 2 - 12 files changed, 6 insertions(+), 224 deletions(-) delete mode 100644 LightlessSync/Services/LocationShareService.cs diff --git a/LightlessAPI b/LightlessAPI index fdd492a..5656600 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit fdd492a8f478949d910ed0efd3e4a3ca3312ed9c +Subproject commit 56566003e0e93bba05dcef49fd3ce23c6a204d81 diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 4131e40..cd57c34 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -137,7 +137,6 @@ public sealed class Plugin : IDalamudPlugin services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(sp => new TextureMetadataHelper(sp.GetRequiredService>(), gameData)); diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 36ce98b..ef6fe7a 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -1,13 +1,11 @@ using Dalamud.Game.ClientState.Conditions; using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Objects.Types; -using Dalamud.Game.Text; using Dalamud.Plugin.Services; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.Game.Character; using FFXIVClientStructs.FFXIV.Client.Game.Control; -using FFXIVClientStructs.FFXIV.Client.Game.UI; using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; using FFXIVClientStructs.FFXIV.Client.UI.Agent; using LightlessSync.API.Dto.CharaData; @@ -27,7 +25,6 @@ using System.Runtime.CompilerServices; using System.Text; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject; -using Map = Lumina.Excel.Sheets.Map; using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags; namespace LightlessSync.Services; @@ -591,7 +588,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber var location = new LocationInfo(); location.ServerId = _playerState.CurrentWorld.RowId; - location.InstanceId = UIState.Instance()->PublicInstance.InstanceId; + //location.InstanceId = UIState.Instance()->PublicInstance.InstanceId; //TODO:Need API update first location.TerritoryId = _clientState.TerritoryType; location.MapId = _clientState.MapId; if (houseMan != null) @@ -645,10 +642,10 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber str += $" - {MapData.Value[(ushort)location.MapId].MapName}"; } - if (location.InstanceId is not 0) - { - str += ((SeIconChar)(57520 + location.InstanceId)).ToIconString(); - } + // if (location.InstanceId is not 0) + // { + // str += ((SeIconChar)(57520 + location.InstanceId)).ToIconString(); + // } if (location.WardId is not 0) { diff --git a/LightlessSync/Services/LocationShareService.cs b/LightlessSync/Services/LocationShareService.cs deleted file mode 100644 index 0d3f5dc..0000000 --- a/LightlessSync/Services/LocationShareService.cs +++ /dev/null @@ -1,78 +0,0 @@ -using LightlessSync.API.Data; -using LightlessSync.API.Dto.CharaData; -using LightlessSync.API.Dto.User; -using LightlessSync.Services.Mediator; -using LightlessSync.WebAPI; -using Microsoft.Extensions.Logging; - -namespace LightlessSync.Services -{ - public class LocationShareService : DisposableMediatorSubscriberBase - { - private readonly DalamudUtilService _dalamudUtilService; - private readonly ApiController _apiController; - private Dictionary _locations = []; - - public LocationShareService(ILogger logger, LightlessMediator mediator, DalamudUtilService dalamudUtilService, ApiController apiController) : base(logger, mediator) - { - _dalamudUtilService = dalamudUtilService; - _apiController = apiController; - - - Mediator.Subscribe(this, (msg) => _locations.Clear()); - Mediator.Subscribe(this, (msg) => - { - _ = _apiController.UpdateLocation(new LocationDto(new UserData(_apiController.UID, apiController.DisplayName), _dalamudUtilService.GetMapData())); - _ = RequestAllLocation(); - } ); - Mediator.Subscribe(this, UpdateLocationList); - Mediator.Subscribe(this, - msg => _ = _apiController.UpdateLocation(new LocationDto(new UserData(_apiController.UID, _apiController.DisplayName), _dalamudUtilService.GetMapData()))); - } - - private void UpdateLocationList(LocationMessage msg) - { - if (_locations.ContainsKey(msg.Uid) && msg.LocationInfo.ServerId is 0) - { - _locations.Remove(msg.Uid); - return; - } - - if ( msg.LocationInfo.ServerId is not 0 && !_locations.TryAdd(msg.Uid, msg.LocationInfo)) - { - _locations[msg.Uid] = msg.LocationInfo; - } - } - - private async Task RequestAllLocation() - { - try - { - var data = await _apiController.RequestAllLocationInfo().ConfigureAwait(false); - _locations = data.ToDictionary(x => x.user.UID, x => x.location, StringComparer.Ordinal); - } - catch (Exception e) - { - Logger.LogError(e,"RequestAllLocation error : "); - throw; - } - } - - public string GetUserLocation(string uid) - { - try - { - if (_locations.TryGetValue(uid, out var location)) - { - return _dalamudUtilService.LocationToString(location); - } - return String.Empty; - } - catch (Exception e) - { - Logger.LogError(e,"GetUserLocation error : "); - throw; - } - } - } -} \ No newline at end of file diff --git a/LightlessSync/Services/Mediator/Messages.cs b/LightlessSync/Services/Mediator/Messages.cs index be0c06b..758b9f5 100644 --- a/LightlessSync/Services/Mediator/Messages.cs +++ b/LightlessSync/Services/Mediator/Messages.cs @@ -135,7 +135,5 @@ public record ChatChannelsUpdated : MessageBase; public record ChatChannelMessageAdded(string ChannelKey, ChatMessageEntry Message) : MessageBase; public record GroupCollectionChangedMessage : MessageBase; public record OpenUserProfileMessage(UserData User) : MessageBase; -public record LocationMessage(string Uid, LocationInfo LocationInfo) : MessageBase; - #pragma warning restore S2094 #pragma warning restore MA0048 // File name must match type name \ No newline at end of file diff --git a/LightlessSync/UI/Components/DrawFolderGroup.cs b/LightlessSync/UI/Components/DrawFolderGroup.cs index 277115a..c39326c 100644 --- a/LightlessSync/UI/Components/DrawFolderGroup.cs +++ b/LightlessSync/UI/Components/DrawFolderGroup.cs @@ -131,7 +131,6 @@ public class DrawFolderGroup : DrawFolderBase bool disableSounds = perm.IsDisableSounds(); bool disableAnims = perm.IsDisableAnimations(); bool disableVfx = perm.IsDisableVFX(); - bool shareLocation = perm.IsSharingLocation(); if ((_groupFullInfoDto.GroupPermissions.IsPreferDisableAnimations() != disableAnims || _groupFullInfoDto.GroupPermissions.IsPreferDisableSounds() != disableSounds @@ -165,13 +164,6 @@ public class DrawFolderGroup : DrawFolderBase _ = _apiController.GroupChangeIndividualPermissionState(new(_groupFullInfoDto.Group, new(_apiController.UID), perm)); ImGui.CloseCurrentPopup(); } - - if (_uiSharedService.IconTextButton(!shareLocation ? FontAwesomeIcon.Globe : FontAwesomeIcon.StopCircle, !shareLocation ? "Share your location to all users in Syncshell" : "STOP Share your location to all users in Syncshell", menuWidth, true)) - { - perm.SetShareLocation(!shareLocation); - _ = _apiController.GroupChangeIndividualPermissionState(new(_groupFullInfoDto.Group, new(_apiController.UID), perm)); - ImGui.CloseCurrentPopup(); - } if (IsModerator || IsOwner) { diff --git a/LightlessSync/UI/Components/DrawUserPair.cs b/LightlessSync/UI/Components/DrawUserPair.cs index 5f3d300..8e03ae4 100644 --- a/LightlessSync/UI/Components/DrawUserPair.cs +++ b/LightlessSync/UI/Components/DrawUserPair.cs @@ -37,7 +37,6 @@ public class DrawUserPair private readonly UiSharedService _uiSharedService; private readonly PlayerPerformanceConfigService _performanceConfigService; private readonly LightlessConfigService _configService; - private readonly LocationShareService _locationShareService; private readonly CharaDataManager _charaDataManager; private readonly PairLedger _pairLedger; private float _menuWidth = -1; @@ -58,7 +57,6 @@ public class DrawUserPair UiSharedService uiSharedService, PlayerPerformanceConfigService performanceConfigService, LightlessConfigService configService, - LocationShareService locationShareService, CharaDataManager charaDataManager, PairLedger pairLedger) { @@ -76,7 +74,6 @@ public class DrawUserPair _uiSharedService = uiSharedService; _performanceConfigService = performanceConfigService; _configService = configService; - _locationShareService = locationShareService; _charaDataManager = charaDataManager; _pairLedger = pairLedger; } @@ -219,17 +216,6 @@ public class DrawUserPair _ = _apiController.UserSetPairPermissions(new UserPermissionsDto(_pair.UserData, permissions)); } UiSharedService.AttachToolTip("Changes VFX sync permissions with this user." + (individual ? individualText : string.Empty)); - - var isShareingLocation = _pair.UserPair!.OwnPermissions.IsSharingLocation(); - string isShareingLocationText = isShareingLocation ? "Disable location sharing" : "Enable location sharing"; - var isShareingLocationIcon = isShareingLocation ? FontAwesomeIcon.StopCircle : FontAwesomeIcon.Globe; - if (_uiSharedService.IconTextButton(isShareingLocationIcon, isShareingLocationText, _menuWidth, true)) - { - var permissions = _pair.UserPair.OwnPermissions; - permissions.SetShareLocation(!isShareingLocation); - _ = _apiController.UserSetPairPermissions(new UserPermissionsDto(_pair.UserData, permissions)); - } - UiSharedService.AttachToolTip("Changes location sharing permissions with this user." + (individual ? individualText : string.Empty)); } private void DrawIndividualMenu() @@ -581,7 +567,6 @@ public class DrawUserPair : UiSharedService.TooltipSeparator + "Hold CTRL to enable preferred permissions while pausing." + Environment.NewLine + "This will leave this pair paused even if unpausing syncshells including this pair.")) : "Resume pairing with " + _pair.UserData.AliasOrUID); - //Location sharing if (_pair.IsPaired) { var individualSoundsDisabled = (_pair.UserPair?.OwnPermissions.IsDisableSounds() ?? false) || (_pair.UserPair?.OtherPermissions.IsDisableSounds() ?? false); @@ -589,66 +574,6 @@ public class DrawUserPair var individualVFXDisabled = (_pair.UserPair?.OwnPermissions.IsDisableVFX() ?? false) || (_pair.UserPair?.OtherPermissions.IsDisableVFX() ?? false); var individualIsSticky = _pair.UserPair!.OwnPermissions.IsSticky(); var individualIcon = individualIsSticky ? FontAwesomeIcon.ArrowCircleUp : FontAwesomeIcon.InfoCircle; - - - var shareLocationIcon = FontAwesomeIcon.Globe; - var shareLocation = _pair.UserPair?.OwnPermissions.IsSharingLocation() ?? false; - var shareLocationOther = _pair.UserPair?.OtherPermissions.IsSharingLocation() ?? false; - var shareColor = shareLocation switch - { - true when shareLocationOther => UIColors.Get("LightlessGreen"), - false when shareLocationOther => UIColors.Get("LightlessBlue"), - _ => UIColors.Get("LightlessYellow"), - }; - - if (shareLocation || shareLocationOther) - { - currentRightSide -= (_uiSharedService.GetIconSize(shareLocationIcon).X + spacingX); - ImGui.SameLine(currentRightSide); - using (ImRaii.PushColor(ImGuiCol.Text, shareColor, shareLocation || shareLocationOther)) - _uiSharedService.IconText(shareLocationIcon); - - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - if (shareLocationOther) - { - var location = _locationShareService.GetUserLocation(_pair.UserPair!.User.UID); - if (_pair.IsOnline) - { - if (!string.IsNullOrEmpty(location)) - { - _uiSharedService.IconText(FontAwesomeIcon.LocationArrow); - ImGui.SameLine(); - ImGui.TextUnformatted(location); - } - else - { - ImGui.TextUnformatted("Location info not updated, reconnect or waiting for zone-changing."); - } - } - else - { - ImGui.TextUnformatted("User not onlineㄟ( ▔, ▔ )ㄏ"); - } - } - else - { - ImGui.TextUnformatted("NOT Sharing location with you.(⊙x⊙;)"); - } - ImGui.Separator(); - - if (shareLocation) - { - ImGui.TextUnformatted("Sharing your location.ヾ(•ω•`)o"); - } - else - { - ImGui.TextUnformatted("NOT sharing your location.(´。_。`)"); - } - ImGui.EndTooltip(); - } - } if (individualAnimDisabled || individualSoundsDisabled || individualVFXDisabled || individualIsSticky) { diff --git a/LightlessSync/UI/DrawEntityFactory.cs b/LightlessSync/UI/DrawEntityFactory.cs index e810a29..3c71f5c 100644 --- a/LightlessSync/UI/DrawEntityFactory.cs +++ b/LightlessSync/UI/DrawEntityFactory.cs @@ -28,7 +28,6 @@ public class DrawEntityFactory private readonly ServerConfigurationManager _serverConfigurationManager; private readonly LightlessConfigService _configService; private readonly UiSharedService _uiSharedService; - private readonly LocationShareService _locationShareService; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; private readonly CharaDataManager _charaDataManager; private readonly SelectTagForPairUi _selectTagForPairUi; @@ -53,7 +52,6 @@ public class DrawEntityFactory ServerConfigurationManager serverConfigurationManager, LightlessConfigService configService, UiSharedService uiSharedService, - LocationShareService locationShareService, PlayerPerformanceConfigService playerPerformanceConfigService, CharaDataManager charaDataManager, SelectTagForSyncshellUi selectTagForSyncshellUi, @@ -73,7 +71,6 @@ public class DrawEntityFactory _serverConfigurationManager = serverConfigurationManager; _configService = configService; _uiSharedService = uiSharedService; - _locationShareService = locationShareService; _playerPerformanceConfigService = playerPerformanceConfigService; _charaDataManager = charaDataManager; _selectTagForSyncshellUi = selectTagForSyncshellUi; @@ -165,7 +162,6 @@ public class DrawEntityFactory _uiSharedService, _playerPerformanceConfigService, _configService, - _locationShareService, _charaDataManager, _pairLedger); } diff --git a/LightlessSync/UI/PermissionWindowUI.cs b/LightlessSync/UI/PermissionWindowUI.cs index 9d88547..5dee098 100644 --- a/LightlessSync/UI/PermissionWindowUI.cs +++ b/LightlessSync/UI/PermissionWindowUI.cs @@ -43,7 +43,6 @@ public class PermissionWindowUI : WindowMediatorSubscriberBase var disableSounds = _ownPermissions.IsDisableSounds(); var disableAnimations = _ownPermissions.IsDisableAnimations(); var disableVfx = _ownPermissions.IsDisableVFX(); - var shareLocation = _ownPermissions.IsSharingLocation(); var style = ImGui.GetStyle(); var indentSize = ImGui.GetFrameHeight() + style.ItemSpacing.X; @@ -71,7 +70,6 @@ public class PermissionWindowUI : WindowMediatorSubscriberBase var otherDisableSounds = otherPerms.IsDisableSounds(); var otherDisableAnimations = otherPerms.IsDisableAnimations(); var otherDisableVFX = otherPerms.IsDisableVFX(); - var otherShareLocation = otherPerms.IsSharingLocation(); using (ImRaii.PushIndent(indentSize, false)) { @@ -126,24 +124,6 @@ public class PermissionWindowUI : WindowMediatorSubscriberBase ImGui.AlignTextToFramePadding(); ImGui.Text(Pair.UserData.AliasOrUID + " has " + (!otherDisableVFX ? "not " : string.Empty) + "disabled VFX sync with you"); } - - if (ImGui.Checkbox("Enable location Sharing", ref shareLocation)) - { - _ownPermissions.SetShareLocation(shareLocation); - } - _uiSharedService.DrawHelpText("Enable location sharing will only effect your side." + UiSharedService.TooltipSeparator - + "Note: this is NOT bidirectional, you can choose to share even others dont share with you."); - using (ImRaii.PushIndent(indentSize, false)) - { - _uiSharedService.BooleanToColoredIcon(shareLocation, false); - ImGui.SameLine(); - ImGui.AlignTextToFramePadding(); - ImGui.Text((!shareLocation ? "Not" : string.Empty) + "sharing location with " + Pair.UserData.AliasOrUID + " ."); - -#if DEBUG - _uiSharedService.BooleanToColoredIcon(otherShareLocation, true); -#endif - } ImGuiHelpers.ScaledDummy(0.5f); ImGui.Separator(); diff --git a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs index 6cd704f..fdc4719 100644 --- a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs +++ b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs @@ -200,17 +200,5 @@ public partial class ApiController await UserPushData(new(visibleCharacters, character, censusDto)).ConfigureAwait(false); } - - public async Task UpdateLocation(LocationDto locationDto, bool offline = false) - { - if (!IsConnected) return; - await _lightlessHub!.SendAsync(nameof(UpdateLocation), locationDto, offline).ConfigureAwait(false); - } - - public async Task> RequestAllLocationInfo() - { - if (!IsConnected) return []; - return await _lightlessHub!.InvokeAsync>(nameof(RequestAllLocationInfo)).ConfigureAwait(false); - } } #pragma warning restore MA0040 \ No newline at end of file diff --git a/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs b/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs index 01abc88..490800f 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs @@ -259,13 +259,6 @@ public partial class ApiController ExecuteSafely(() => Mediator.Publish(new GPoseLobbyReceiveWorldData(userData, worldData))); return Task.CompletedTask; } - - public Task Client_SendLocationToClient(LocationDto locationDto) - { - Logger.LogDebug($"{nameof(Client_SendLocationToClient)}: {locationDto.user}"); - ExecuteSafely(() => Mediator.Publish(new LocationMessage(locationDto.user.UID, locationDto.location))); - return Task.CompletedTask; - } public void OnDownloadReady(Action act) { @@ -447,12 +440,6 @@ public partial class ApiController if (_initialized) return; _lightlessHub!.On(nameof(Client_GposeLobbyPushWorldData), act); } - - public void OnReciveLocation(Action act) - { - if (_initialized) return; - _lightlessHub!.On(nameof(Client_SendLocationToClient), act); - } private void ExecuteSafely(Action act) { diff --git a/LightlessSync/WebAPI/SignalR/ApiController.cs b/LightlessSync/WebAPI/SignalR/ApiController.cs index a814758..9639f6f 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.cs @@ -606,8 +606,6 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL OnGposeLobbyPushCharacterData((dto) => _ = Client_GposeLobbyPushCharacterData(dto)); OnGposeLobbyPushPoseData((dto, data) => _ = Client_GposeLobbyPushPoseData(dto, data)); OnGposeLobbyPushWorldData((dto, data) => _ = Client_GposeLobbyPushWorldData(dto, data)); - OnReciveLocation(dto => _ = Client_SendLocationToClient(dto)); - _healthCheckTokenSource?.Cancel(); _healthCheckTokenSource?.Dispose(); -- 2.49.1 From a933330418c96b242c2b2d4693522c3bfd322ed0 Mon Sep 17 00:00:00 2001 From: Tsubasahane Date: Sun, 28 Dec 2025 23:07:45 +0800 Subject: [PATCH 13/87] Share location --- LightlessAPI | 2 +- LightlessSync/LightlessSync.csproj | 1 + LightlessSync/Plugin.cs | 1 + LightlessSync/Services/DalamudUtilService.cs | 16 ++- .../Services/LocationShareService.cs | 131 ++++++++++++++++++ LightlessSync/Services/Mediator/Messages.cs | 1 + LightlessSync/UI/Components/DrawUserPair.cs | 102 ++++++++++++++ LightlessSync/UI/DrawEntityFactory.cs | 4 + .../SignalR/ApIController.Functions.Users.cs | 16 +++ .../ApiController.Functions.Callbacks.cs | 13 ++ LightlessSync/WebAPI/SignalR/ApiController.cs | 2 + 11 files changed, 282 insertions(+), 7 deletions(-) create mode 100644 LightlessSync/Services/LocationShareService.cs diff --git a/LightlessAPI b/LightlessAPI index 5656600..852e2a0 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit 56566003e0e93bba05dcef49fd3ce23c6a204d81 +Subproject commit 852e2a005f5bfdf3844e057c6ba71de6f5f84ed8 diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 707d2a3..96efb14 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -37,6 +37,7 @@ + diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 2d46b43..d070831 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -140,6 +140,7 @@ public sealed class Plugin : IDalamudPlugin services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => new TextureMetadataHelper(sp.GetRequiredService>(), gameData)); diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 0b93997..96c78f3 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -1,11 +1,13 @@ using Dalamud.Game.ClientState.Conditions; using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Game.Text; using Dalamud.Plugin.Services; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.Game.Character; using FFXIVClientStructs.FFXIV.Client.Game.Control; +using FFXIVClientStructs.FFXIV.Client.Game.UI; using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; using FFXIVClientStructs.FFXIV.Client.UI.Agent; using LightlessSync.API.Dto.CharaData; @@ -26,6 +28,7 @@ using System.Text; using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject; +using Map = Lumina.Excel.Sheets.Map; using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags; namespace LightlessSync.Services; @@ -86,7 +89,8 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber WorldData = new(() => { return gameData.GetExcelSheet(clientLanguage)! - .Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0]))) + .Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0]) + || w is { RowId: > 1000, Region: 101 })) .ToDictionary(w => (ushort)w.RowId, w => w.Name.ToString()); }); JobData = new(() => @@ -659,7 +663,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber var location = new LocationInfo(); location.ServerId = _playerState.CurrentWorld.RowId; - //location.InstanceId = UIState.Instance()->PublicInstance.InstanceId; //TODO:Need API update first + location.InstanceId = UIState.Instance()->PublicInstance.InstanceId; location.TerritoryId = _clientState.TerritoryType; location.MapId = _clientState.MapId; if (houseMan != null) @@ -713,10 +717,10 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber str += $" - {MapData.Value[(ushort)location.MapId].MapName}"; } - // if (location.InstanceId is not 0) - // { - // str += ((SeIconChar)(57520 + location.InstanceId)).ToIconString(); - // } + if (location.InstanceId is not 0) + { + str += ((SeIconChar)(57520 + location.InstanceId)).ToIconString(); + } if (location.WardId is not 0) { diff --git a/LightlessSync/Services/LocationShareService.cs b/LightlessSync/Services/LocationShareService.cs new file mode 100644 index 0000000..71989e5 --- /dev/null +++ b/LightlessSync/Services/LocationShareService.cs @@ -0,0 +1,131 @@ +using LightlessSync.API.Data; +using LightlessSync.API.Dto.CharaData; +using LightlessSync.API.Dto.User; +using LightlessSync.Services.Mediator; +using LightlessSync.WebAPI; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; + +namespace LightlessSync.Services +{ + public class LocationShareService : DisposableMediatorSubscriberBase + { + private readonly DalamudUtilService _dalamudUtilService; + private readonly ApiController _apiController; + private IMemoryCache _locations = new MemoryCache(new MemoryCacheOptions()); + private IMemoryCache _sharingStatus = new MemoryCache(new MemoryCacheOptions()); + private CancellationTokenSource _resetToken = new CancellationTokenSource(); + + + + public LocationShareService(ILogger logger, LightlessMediator mediator, DalamudUtilService dalamudUtilService, ApiController apiController) : base(logger, mediator) + { + _dalamudUtilService = dalamudUtilService; + _apiController = apiController; + + + Mediator.Subscribe(this, (msg) => + { + _resetToken.Cancel(); + _resetToken.Dispose(); + _resetToken = new CancellationTokenSource(); + }); + Mediator.Subscribe(this, (msg) => + { + _ = _apiController.UpdateLocation(new LocationDto(new UserData(_apiController.UID, apiController.DisplayName), _dalamudUtilService.GetMapData())); + _ = RequestAllLocation(); + } ); + Mediator.Subscribe(this, UpdateLocationList); + Mediator.Subscribe(this, + msg => _ = _apiController.UpdateLocation(new LocationDto(new UserData(_apiController.UID, _apiController.DisplayName), _dalamudUtilService.GetMapData()))); + } + + private void UpdateLocationList(LocationSharingMessage msg) + { + if (_locations.TryGetValue(msg.User.UID, out _) && msg.LocationInfo.ServerId is 0) + { + _locations.Remove(msg.User.UID); + return; + } + + if ( msg.LocationInfo.ServerId is not 0 && msg.ExpireAt > DateTime.UtcNow) + { + AddLocationInfo(msg.User.UID, msg.LocationInfo, msg.ExpireAt); + } + } + + private void AddLocationInfo(string uid, LocationInfo location, DateTimeOffset expireAt) + { + var options = new MemoryCacheEntryOptions() + .SetAbsoluteExpiration(expireAt) + .AddExpirationToken(new CancellationChangeToken(_resetToken.Token)); + _locations.Set(uid, location, options); + } + + private async Task RequestAllLocation() + { + try + { + var (data, status) = await _apiController.RequestAllLocationInfo().ConfigureAwait(false); + foreach (var dto in data) + { + AddLocationInfo(dto.LocationDto.User.UID, dto.LocationDto.Location, dto.ExpireAt); + } + + foreach (var dto in status) + { + AddStatus(dto.User.UID, dto.ExpireAt); + } + } + catch (Exception e) + { + Logger.LogError(e,"RequestAllLocation error : "); + throw; + } + } + + private void AddStatus(string uid, DateTimeOffset expireAt) + { + var options = new MemoryCacheEntryOptions() + .SetAbsoluteExpiration(expireAt) + .AddExpirationToken(new CancellationChangeToken(_resetToken.Token)); + _sharingStatus.Set(uid, expireAt, options); + } + + public string GetUserLocation(string uid) + { + try + { + if (_locations.TryGetValue(uid, out var location)) + { + return _dalamudUtilService.LocationToString(location); + } + return String.Empty; + } + catch (Exception e) + { + Logger.LogError(e,"GetUserLocation error : "); + throw; + } + } + + public DateTimeOffset GetSharingStatus(string uid) + { + try + { + if (_sharingStatus.TryGetValue(uid, out var expireAt)) + { + return expireAt; + } + return DateTimeOffset.MinValue; + } + catch (Exception e) + { + Logger.LogError(e,"GetSharingStatus error : "); + throw; + } + } + + } +} \ No newline at end of file diff --git a/LightlessSync/Services/Mediator/Messages.cs b/LightlessSync/Services/Mediator/Messages.cs index 758b9f5..00f8de7 100644 --- a/LightlessSync/Services/Mediator/Messages.cs +++ b/LightlessSync/Services/Mediator/Messages.cs @@ -135,5 +135,6 @@ public record ChatChannelsUpdated : MessageBase; public record ChatChannelMessageAdded(string ChannelKey, ChatMessageEntry Message) : MessageBase; public record GroupCollectionChangedMessage : MessageBase; public record OpenUserProfileMessage(UserData User) : MessageBase; +public record LocationSharingMessage(UserData User, LocationInfo LocationInfo, DateTimeOffset ExpireAt) : MessageBase; #pragma warning restore S2094 #pragma warning restore MA0048 // File name must match type name \ No newline at end of file diff --git a/LightlessSync/UI/Components/DrawUserPair.cs b/LightlessSync/UI/Components/DrawUserPair.cs index a5fa953..b8e58c4 100644 --- a/LightlessSync/UI/Components/DrawUserPair.cs +++ b/LightlessSync/UI/Components/DrawUserPair.cs @@ -37,6 +37,7 @@ public class DrawUserPair private readonly UiSharedService _uiSharedService; private readonly PlayerPerformanceConfigService _performanceConfigService; private readonly LightlessConfigService _configService; + private readonly LocationShareService _locationShareService; private readonly CharaDataManager _charaDataManager; private readonly PairLedger _pairLedger; private float _menuWidth = -1; @@ -57,6 +58,7 @@ public class DrawUserPair UiSharedService uiSharedService, PlayerPerformanceConfigService performanceConfigService, LightlessConfigService configService, + LocationShareService locationShareService, CharaDataManager charaDataManager, PairLedger pairLedger) { @@ -74,6 +76,7 @@ public class DrawUserPair _uiSharedService = uiSharedService; _performanceConfigService = performanceConfigService; _configService = configService; + _locationShareService = locationShareService; _charaDataManager = charaDataManager; _pairLedger = pairLedger; } @@ -216,6 +219,41 @@ public class DrawUserPair _ = _apiController.UserSetPairPermissions(new UserPermissionsDto(_pair.UserData, permissions)); } UiSharedService.AttachToolTip("Changes VFX sync permissions with this user." + (individual ? individualText : string.Empty)); + + if (ImGui.BeginMenu(FontAwesomeIcon.Globe.ToIconString() + " Toggle Location sharing")) + { + if (ImGui.MenuItem("Share for 30 Mins")) + { + _ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.UtcNow.AddMinutes(30)); + } + + if (ImGui.MenuItem("Share for 1 Hour")) + { + _ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.UtcNow.AddHours(1)); + } + + if (ImGui.MenuItem("Share for 3 Hours")) + { + _ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.UtcNow.AddHours(3)); + } + + if (ImGui.MenuItem("Share until manually stop")) + { + _ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.MaxValue); + } + + ImGui.Separator(); + if (ImGui.MenuItem("Stop Sharing")) + { + _ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.MinValue); + } + ImGui.EndMenu(); + } + } + + private Task ToggleLocationSharing(List users, DateTimeOffset expireAt) + { + return _apiController.ToggleLocationSharing(new LocationSharingToggleDto(users, expireAt)); } private void DrawIndividualMenu() @@ -574,6 +612,70 @@ public class DrawUserPair var individualVFXDisabled = (_pair.UserPair?.OwnPermissions.IsDisableVFX() ?? false) || (_pair.UserPair?.OtherPermissions.IsDisableVFX() ?? false); var individualIsSticky = _pair.UserPair!.OwnPermissions.IsSticky(); var individualIcon = individualIsSticky ? FontAwesomeIcon.ArrowCircleUp : FontAwesomeIcon.InfoCircle; + + var shareLocationIcon = FontAwesomeIcon.Globe; + var location = _locationShareService.GetUserLocation(_pair.UserPair!.User.UID); + var shareLocation = !string.IsNullOrEmpty(location); + var expireAt = _locationShareService.GetSharingStatus(_pair.UserPair!.User.UID); + var shareLocationOther = expireAt > DateTimeOffset.UtcNow; + var shareColor = shareLocation switch + { + true when shareLocationOther => UIColors.Get("LightlessGreen"), + false when shareLocationOther => UIColors.Get("LightlessBlue"), + _ => UIColors.Get("LightlessYellow"), + }; + + if (shareLocation || shareLocationOther) + { + currentRightSide -= (_uiSharedService.GetIconSize(shareLocationIcon).X + spacingX); + ImGui.SameLine(currentRightSide); + using (ImRaii.PushColor(ImGuiCol.Text, shareColor, shareLocation || shareLocationOther)) + _uiSharedService.IconText(shareLocationIcon); + + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + if (shareLocationOther) + { + if (_pair.IsOnline) + { + if (!string.IsNullOrEmpty(location)) + { + _uiSharedService.IconText(FontAwesomeIcon.LocationArrow); + ImGui.SameLine(); + ImGui.TextUnformatted(location); + } + else + { + ImGui.TextUnformatted("Location info not updated, reconnect or wait for update."); + } + } + else + { + ImGui.TextUnformatted("User not onlineㄟ( ▔, ▔ )ㄏ"); + } + } + else + { + ImGui.TextUnformatted("NOT Sharing location with you.(⊙x⊙;)"); + } + ImGui.Separator(); + + if (shareLocation) + { + ImGui.TextUnformatted("Sharing your location.ヾ(•ω•`)o"); + if (expireAt != DateTimeOffset.MaxValue) + { + ImGui.TextUnformatted("Expired at " + expireAt.ToLocalTime().ToString("g")); + } + } + else + { + ImGui.TextUnformatted("NOT sharing your location.(´。_。`)"); + } + ImGui.EndTooltip(); + } + } if (individualAnimDisabled || individualSoundsDisabled || individualVFXDisabled || individualIsSticky) { diff --git a/LightlessSync/UI/DrawEntityFactory.cs b/LightlessSync/UI/DrawEntityFactory.cs index 3c71f5c..e7bcc87 100644 --- a/LightlessSync/UI/DrawEntityFactory.cs +++ b/LightlessSync/UI/DrawEntityFactory.cs @@ -29,6 +29,7 @@ public class DrawEntityFactory private readonly LightlessConfigService _configService; private readonly UiSharedService _uiSharedService; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; + private readonly LocationShareService _locationShareService; private readonly CharaDataManager _charaDataManager; private readonly SelectTagForPairUi _selectTagForPairUi; private readonly RenamePairTagUi _renamePairTagUi; @@ -53,6 +54,7 @@ public class DrawEntityFactory LightlessConfigService configService, UiSharedService uiSharedService, PlayerPerformanceConfigService playerPerformanceConfigService, + LocationShareService locationShareService, CharaDataManager charaDataManager, SelectTagForSyncshellUi selectTagForSyncshellUi, RenameSyncshellTagUi renameSyncshellTagUi, @@ -72,6 +74,7 @@ public class DrawEntityFactory _configService = configService; _uiSharedService = uiSharedService; _playerPerformanceConfigService = playerPerformanceConfigService; + _locationShareService = locationShareService; _charaDataManager = charaDataManager; _selectTagForSyncshellUi = selectTagForSyncshellUi; _renameSyncshellTagUi = renameSyncshellTagUi; @@ -162,6 +165,7 @@ public class DrawEntityFactory _uiSharedService, _playerPerformanceConfigService, _configService, + _locationShareService, _charaDataManager, _pairLedger); } diff --git a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs index fdc4719..fca42f3 100644 --- a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs +++ b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs @@ -200,5 +200,21 @@ public partial class ApiController await UserPushData(new(visibleCharacters, character, censusDto)).ConfigureAwait(false); } + + public async Task UpdateLocation(LocationDto locationDto, bool offline = false) + { + if (!IsConnected) return; + await _lightlessHub!.SendAsync(nameof(UpdateLocation), locationDto, offline).ConfigureAwait(false); + } + public async Task<(List, List)> RequestAllLocationInfo() + { + if (!IsConnected) return ([],[]); + return await _lightlessHub!.InvokeAsync<(List, List)>(nameof(RequestAllLocationInfo)).ConfigureAwait(false); + } + public async Task ToggleLocationSharing(LocationSharingToggleDto dto) + { + if (!IsConnected) return; + await _lightlessHub!.SendAsync(nameof(ToggleLocationSharing), dto).ConfigureAwait(false); + } } #pragma warning restore MA0040 \ No newline at end of file diff --git a/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs b/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs index 490800f..d0a05f7 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs @@ -259,6 +259,13 @@ public partial class ApiController ExecuteSafely(() => Mediator.Publish(new GPoseLobbyReceiveWorldData(userData, worldData))); return Task.CompletedTask; } + + public Task Client_SendLocationToClient(LocationDto locationDto, DateTimeOffset expireAt) + { + Logger.LogDebug($"{nameof(Client_SendLocationToClient)}: {locationDto.User} {expireAt}"); + ExecuteSafely(() => Mediator.Publish(new LocationSharingMessage(locationDto.User, locationDto.Location, expireAt))); + return Task.CompletedTask; + } public void OnDownloadReady(Action act) { @@ -441,6 +448,12 @@ public partial class ApiController _lightlessHub!.On(nameof(Client_GposeLobbyPushWorldData), act); } + public void OnReceiveLocation(Action act) + { + if (_initialized) return; + _lightlessHub!.On(nameof(Client_SendLocationToClient), act); + } + private void ExecuteSafely(Action act) { try diff --git a/LightlessSync/WebAPI/SignalR/ApiController.cs b/LightlessSync/WebAPI/SignalR/ApiController.cs index 9639f6f..45705bf 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.cs @@ -606,6 +606,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL OnGposeLobbyPushCharacterData((dto) => _ = Client_GposeLobbyPushCharacterData(dto)); OnGposeLobbyPushPoseData((dto, data) => _ = Client_GposeLobbyPushPoseData(dto, data)); OnGposeLobbyPushWorldData((dto, data) => _ = Client_GposeLobbyPushWorldData(dto, data)); + OnReceiveLocation((dto, time) => _ = Client_SendLocationToClient(dto, time)); _healthCheckTokenSource?.Cancel(); _healthCheckTokenSource?.Dispose(); @@ -774,5 +775,6 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL ServerState = state; } + } #pragma warning restore MA0040 -- 2.49.1 From 9e600bfae06f048ca84da3b5b06957652debef7b Mon Sep 17 00:00:00 2001 From: cake Date: Sun, 28 Dec 2025 16:48:51 +0100 Subject: [PATCH 14/87] Fixed merge conflicts. --- LightlessSync/Plugin.cs | 13 +-- LightlessSync/UI/LightFinderUI.cs | 157 +++++++++++++++--------------- 2 files changed, 81 insertions(+), 89 deletions(-) diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 69a426d..1a4a7bd 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -480,20 +480,11 @@ public sealed class Plugin : IDalamudPlugin sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); - - services.AddScoped(sp => new SyncshellFinderUI( - sp.GetRequiredService>(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService())); services.AddScoped(); services.AddScoped(); diff --git a/LightlessSync/UI/LightFinderUI.cs b/LightlessSync/UI/LightFinderUI.cs index 439bc4c..e9aec9c 100644 --- a/LightlessSync/UI/LightFinderUI.cs +++ b/LightlessSync/UI/LightFinderUI.cs @@ -37,6 +37,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase private readonly LightFinderService _broadcastService; private readonly LightlessConfigService _configService; private readonly LightlessProfileManager _lightlessProfileManager; + private readonly LightFinderPlateHandler _lightFinderPlateHandler; private readonly PairUiService _pairUiService; private readonly UiSharedService _uiSharedService; @@ -100,7 +101,8 @@ public class LightFinderUI : WindowMediatorSubscriberBase DalamudUtilService dalamudUtilService, LightlessProfileManager lightlessProfileManager, ActorObjectService actorObjectService - ) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService) +, + LightFinderPlateHandler lightFinderPlateHandler) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService) { _broadcastService = broadcastService; _uiSharedService = uiShared; @@ -126,6 +128,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase Mediator.Subscribe(this, async _ => await RefreshNearbySyncshellsAsync().ConfigureAwait(false)); Mediator.Subscribe(this, async _ => await RefreshNearbySyncshellsAsync(_.gid).ConfigureAwait(false)); Mediator.Subscribe(this, async _ => await RefreshNearbySyncshellsAsync(_.gid).ConfigureAwait(false)); + _lightFinderPlateHandler = lightFinderPlateHandler; } #endregion @@ -1377,96 +1380,94 @@ public class LightFinderUI : WindowMediatorSubscriberBase ImGui.PopStyleVar(); } - #endregion - + private void DrawDebugTab() + { #if DEBUG - if (ImGui.BeginTabItem("Debug")) + if (ImGui.CollapsingHeader("LightFinder Plates", ImGuiTreeNodeFlags.DefaultOpen)) + { + var h = _lightFinderPlateHandler; + + var enabled = h.DebugEnabled; + if (ImGui.Checkbox("Enable LightFinder debug", ref enabled)) + h.DebugEnabled = enabled; + + if (h.DebugEnabled) { - if (ImGui.CollapsingHeader("LightFinder Plates", ImGuiTreeNodeFlags.DefaultOpen)) - { - var h = _lightFinderPlateHandler; + ImGui.Indent(); - var enabled = h.DebugEnabled; - if (ImGui.Checkbox("Enable LightFinder debug", ref enabled)) - h.DebugEnabled = enabled; + var disableOcc = h.DebugDisableOcclusion; + if (ImGui.Checkbox("Disable occlusion (force draw)", ref disableOcc)) + h.DebugDisableOcclusion = disableOcc; - if (h.DebugEnabled) - { - ImGui.Indent(); + var drawUiRects = h.DebugDrawUiRects; + if (ImGui.Checkbox("Draw UI rects", ref drawUiRects)) + h.DebugDrawUiRects = drawUiRects; - var disableOcc = h.DebugDisableOcclusion; - if (ImGui.Checkbox("Disable occlusion (force draw)", ref disableOcc)) - h.DebugDisableOcclusion = disableOcc; - - var drawUiRects = h.DebugDrawUiRects; - if (ImGui.Checkbox("Draw UI rects", ref drawUiRects)) - h.DebugDrawUiRects = drawUiRects; - - var drawLabelRects = h.DebugDrawLabelRects; - if (ImGui.Checkbox("Draw label rects", ref drawLabelRects)) - h.DebugDrawLabelRects = drawLabelRects; - - ImGui.Separator(); - ImGui.TextUnformatted($"Labels last frame: {h.DebugLabelCountLastFrame}"); - ImGui.TextUnformatted($"UI rects last frame: {h.DebugUiRectCountLastFrame}"); - ImGui.TextUnformatted($"Occluded last frame: {h.DebugOccludedCountLastFrame}"); - ImGui.TextUnformatted($"Last NamePlate frame: {h.DebugLastNameplateFrame}"); - - ImGui.Unindent(); - } - } + var drawLabelRects = h.DebugDrawLabelRects; + if (ImGui.Checkbox("Draw label rects", ref drawLabelRects)) + h.DebugDrawLabelRects = drawLabelRects; ImGui.Separator(); + ImGui.TextUnformatted($"Labels last frame: {h.DebugLabelCountLastFrame}"); + ImGui.TextUnformatted($"UI rects last frame: {h.DebugUiRectCountLastFrame}"); + ImGui.TextUnformatted($"Occluded last frame: {h.DebugOccludedCountLastFrame}"); + ImGui.TextUnformatted($"Last NamePlate frame: {h.DebugLastNameplateFrame}"); - ImGui.Text("Broadcast Cache"); - - if (ImGui.BeginTable("##BroadcastCacheTable", 4, - ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.ScrollY, - new Vector2(-1, 225f))) - { - ImGui.TableSetupColumn("CID", ImGuiTableColumnFlags.WidthStretch); - ImGui.TableSetupColumn("IsBroadcasting", ImGuiTableColumnFlags.WidthStretch); - ImGui.TableSetupColumn("Expires In", ImGuiTableColumnFlags.WidthStretch); - ImGui.TableSetupColumn("Syncshell GID", ImGuiTableColumnFlags.WidthStretch); - ImGui.TableHeadersRow(); - - var now = DateTime.UtcNow; - - foreach (var (cid, entry) in _broadcastScannerService.BroadcastCache) - { - ImGui.TableNextRow(); - - ImGui.TableNextColumn(); - ImGui.TextUnformatted(cid.Truncate(12)); - if (ImGui.IsItemHovered()) - ImGui.SetTooltip(cid); - - ImGui.TableNextColumn(); - var colorBroadcast = entry.IsBroadcasting ? UIColors.Get("LightlessGreen") : UIColors.Get("DimRed"); - ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, ImGui.GetColorU32(colorBroadcast)); - ImGui.TextUnformatted(entry.IsBroadcasting.ToString()); - - ImGui.TableNextColumn(); - var remaining = entry.ExpiryTime - now; - var colorTtl = remaining <= TimeSpan.Zero ? UIColors.Get("DimRed") - : remaining < TimeSpan.FromSeconds(10) ? UIColors.Get("LightlessYellow") - : (Vector4?)null; - - if (colorTtl != null) - ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, ImGui.GetColorU32(colorTtl.Value)); - - ImGui.TextUnformatted(remaining > TimeSpan.Zero ? remaining.ToString("hh\\:mm\\:ss") : "Expired"); - - ImGui.TableNextColumn(); - ImGui.TextUnformatted(entry.GID ?? "-"); + ImGui.Unindent(); + } } - ImGui.EndTable(); - } + ImGui.Separator(); + + ImGui.Text("Broadcast Cache"); + + if (ImGui.BeginTable("##BroadcastCacheTable", 4, + ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.ScrollY, + new Vector2(-1, 225f))) + { + ImGui.TableSetupColumn("CID", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("IsBroadcasting", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Expires In", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Syncshell GID", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableHeadersRow(); + + var now = DateTime.UtcNow; + + foreach (var (cid, entry) in _broadcastScannerService.BroadcastCache) + { + ImGui.TableNextRow(); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(cid.Truncate(12)); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(cid); + + ImGui.TableNextColumn(); + var colorBroadcast = entry.IsBroadcasting ? UIColors.Get("LightlessGreen") : UIColors.Get("DimRed"); + ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, ImGui.GetColorU32(colorBroadcast)); + ImGui.TextUnformatted(entry.IsBroadcasting.ToString()); + + ImGui.TableNextColumn(); + var remaining = entry.ExpiryTime - now; + var colorTtl = remaining <= TimeSpan.Zero ? UIColors.Get("DimRed") + : remaining < TimeSpan.FromSeconds(10) ? UIColors.Get("LightlessYellow") + : (Vector4?)null; + + if (colorTtl != null) + ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, ImGui.GetColorU32(colorTtl.Value)); + + ImGui.TextUnformatted(remaining > TimeSpan.Zero ? remaining.ToString("hh\\:mm\\:ss") : "Expired"); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(entry.GID ?? "-"); + } + + ImGui.EndTable(); + } +#endif } #endregion -#endif #region Data Refresh -- 2.49.1 From d6b31ed5b958c5e2239cfea027f3bb13a634688e Mon Sep 17 00:00:00 2001 From: cake Date: Sun, 28 Dec 2025 16:55:01 +0100 Subject: [PATCH 15/87] Fixed finder again. --- LightlessSync/UI/LightFinderUI.cs | 82 +++++++++++++++---------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/LightlessSync/UI/LightFinderUI.cs b/LightlessSync/UI/LightFinderUI.cs index e9aec9c..0c0cad4 100644 --- a/LightlessSync/UI/LightFinderUI.cs +++ b/LightlessSync/UI/LightFinderUI.cs @@ -178,9 +178,9 @@ public class LightFinderUI : WindowMediatorSubscriberBase new("Debug", LightfinderTabDebug.Debug) }; UiSharedService.Tab("LightfinderTabs", debugTabOptions, ref _selectedTabDebug); - + ImGuiHelpers.ScaledDummy(4f); - + switch (_selectedTabDebug) { case LightfinderTabDebug.NearbySyncshells: @@ -304,14 +304,14 @@ public class LightFinderUI : WindowMediatorSubscriberBase var nearbySyncshellCount = _nearbySyncshells.Count; var nearbySyncshellColor = nearbySyncshellCount > 0 ? UIColors.Get("LightlessPurple") : infoColor; DrawStatusCell(FontAwesomeIcon.Compass, nearbySyncshellColor, nearbySyncshellCount.ToString(), "Syncshells", infoColor, scale); - + // Nearby players cell (exclude self) string? myHashedCidForCount = null; try { myHashedCidForCount = _dalamudUtilService.GetCID().ToString().GetHash256(); } catch { } var nearbyPlayerCount = _broadcastScannerService.CountActiveBroadcasts(myHashedCidForCount); var nearbyPlayerColor = nearbyPlayerCount > 0 ? UIColors.Get("LightlessBlue") : infoColor; DrawStatusCell(FontAwesomeIcon.Users, nearbyPlayerColor, nearbyPlayerCount.ToString(), "Players", infoColor, scale); - + // Broadcasting syncshell cell var isBroadcastingSyncshell = _configService.Current.SyncshellFinderEnabled && isBroadcasting; var broadcastSyncshellColor = isBroadcastingSyncshell ? UIColors.Get("LightlessGreen") : infoColor; @@ -321,7 +321,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase // Enable/Disable button cell - right aligned ImGui.TableNextColumn(); - + float cellWidth = ImGui.GetContentRegionAvail().X; float offsetX = cellWidth - buttonWidth; if (offsetX > 0) @@ -381,11 +381,11 @@ public class LightFinderUI : WindowMediatorSubscriberBase private void UpdateItemAnimations(IEnumerable visibleItemIds) { var deltaTime = ImGui.GetIO().DeltaTime; - + _previousVisibleItems.Clear(); foreach (var id in _currentVisibleItems) _previousVisibleItems.Add(id); - + _currentVisibleItems.Clear(); foreach (var id in visibleItemIds) _currentVisibleItems.Add(id); @@ -395,7 +395,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase { if (!_itemAlpha.ContainsKey(id)) _itemAlpha[id] = 0f; - + _itemAlpha[id] = Math.Min(1f, _itemAlpha[id] + deltaTime * AnimationSpeed); } @@ -410,7 +410,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase toRemove.Add(id); } } - + foreach (var id in toRemove) _itemAlpha.Remove(id); } @@ -668,14 +668,14 @@ public class LightFinderUI : WindowMediatorSubscriberBase ImGui.PopID(); } - private void DrawJoinButton(GroupJoinDto shell, bool fullWidth) + private void DrawJoinButton(GroupJoinDto shell, bool fullWidth) { const string visibleLabel = "Join"; var label = $"{visibleLabel}##{shell.Group.GID}"; var isAlreadyMember = _currentSyncshells.Exists(g => string.Equals(g.GID, shell.Group.GID, StringComparison.Ordinal)); var isRecentlyJoined = _recentlyJoined.Contains(shell.Group.GID); - var isOwnBroadcast = _configService.Current.SyncshellFinderEnabled + var isOwnBroadcast = _configService.Current.SyncshellFinderEnabled && _broadcastService.IsBroadcasting && string.Equals(_configService.Current.SelectedFinderSyncshell, shell.Group.GID, StringComparison.Ordinal); @@ -758,7 +758,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase if (_joinDto == null || _joinInfo == null) return; var scale = ImGuiHelpers.GlobalScale; - + // if not already open if (!ImGui.IsPopupOpen("JoinSyncshellModal")) ImGui.OpenPopup("JoinSyncshellModal"); @@ -774,12 +774,12 @@ public class LightFinderUI : WindowMediatorSubscriberBase ImGui.SetNextWindowSize(new Vector2(modalWidth, modalHeight)); ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * ImGuiHelpers.GlobalScale); ImGui.PushStyleColor(ImGuiCol.Border, Vector4.Zero); - + using ImRaii.Color modalBorder = ImRaii.PushColor(ImGuiCol.Border, UIColors.Get("LightlessPurple").WithAlpha(0.5f)); using ImRaii.Style rounding = ImRaii.PushStyle(ImGuiStyleVar.WindowRounding, 8f * scale); using ImRaii.Style borderSize = ImRaii.PushStyle(ImGuiStyleVar.WindowBorderSize, 2f * scale); using ImRaii.Style padding = ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(16f * scale, 16f * scale)); - + ImGuiWindowFlags flags = ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoScrollbar; if (ImGui.BeginPopupModal("JoinSyncshellModal", ref _joinModalOpen, flags)) { @@ -798,22 +798,22 @@ public class LightFinderUI : WindowMediatorSubscriberBase ImGui.TextColored(ImGuiColors.DalamudWhite, "Permissions"); ImGuiHelpers.ScaledDummy(6f); - DrawPermissionToggleRow("Sounds", FontAwesomeIcon.VolumeUp, - _joinInfo.GroupPermissions.IsPreferDisableSounds(), - _ownPermissions.DisableGroupSounds, - v => _ownPermissions.DisableGroupSounds = v, + DrawPermissionToggleRow("Sounds", FontAwesomeIcon.VolumeUp, + _joinInfo.GroupPermissions.IsPreferDisableSounds(), + _ownPermissions.DisableGroupSounds, + v => _ownPermissions.DisableGroupSounds = v, contentWidth); - DrawPermissionToggleRow("Animations", FontAwesomeIcon.Running, - _joinInfo.GroupPermissions.IsPreferDisableAnimations(), - _ownPermissions.DisableGroupAnimations, - v => _ownPermissions.DisableGroupAnimations = v, + DrawPermissionToggleRow("Animations", FontAwesomeIcon.Running, + _joinInfo.GroupPermissions.IsPreferDisableAnimations(), + _ownPermissions.DisableGroupAnimations, + v => _ownPermissions.DisableGroupAnimations = v, contentWidth); - DrawPermissionToggleRow("VFX", FontAwesomeIcon.Magic, - _joinInfo.GroupPermissions.IsPreferDisableVFX(), - _ownPermissions.DisableGroupVFX, - v => _ownPermissions.DisableGroupVFX = v, + DrawPermissionToggleRow("VFX", FontAwesomeIcon.Magic, + _joinInfo.GroupPermissions.IsPreferDisableVFX(), + _ownPermissions.DisableGroupVFX, + v => _ownPermissions.DisableGroupVFX = v, contentWidth); ImGuiHelpers.ScaledDummy(12f); @@ -879,13 +879,13 @@ public class LightFinderUI : WindowMediatorSubscriberBase var scale = ImGuiHelpers.GlobalScale; float rowHeight = 28f * scale; bool isDifferent = current != suggested; - + using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 4f * scale)) using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 4f * scale)) using (ImRaii.PushColor(ImGuiCol.ChildBg, new Vector4(0.18f, 0.15f, 0.22f, 0.6f))) { ImGui.BeginChild($"PermRow_{label}", new Vector2(contentWidth, rowHeight), false, ImGuiWindowFlags.NoScrollbar); - + float innerPadding = 8f * scale; ImGui.SetCursorPos(new Vector2(innerPadding, (rowHeight - ImGui.GetTextLineHeight()) * 0.5f)); @@ -893,7 +893,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase var enabledColor = UIColors.Get("LightlessGreen"); var disabledColor = UIColors.Get("DimRed"); var currentColor = !current ? enabledColor : disabledColor; - + _uiSharedService.IconText(icon, currentColor); ImGui.SameLine(0f, 6f * scale); ImGui.TextUnformatted(label); @@ -921,7 +921,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase float applyX = contentWidth - 26f * scale; ImGui.SetCursorPosX(applyX); ImGui.SetCursorPosY((rowHeight - ImGui.GetFrameHeight()) * 0.5f); - + using (ImRaii.PushColor(ImGuiCol.Button, UIColors.Get("LightlessGreen").WithAlpha(0.6f))) using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen"))) using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreenDefault"))) @@ -1037,12 +1037,12 @@ public class LightFinderUI : WindowMediatorSubscriberBase } private readonly record struct NearbyPlayerData( - string HashedCid, - string Name, - string? World, - nint Address, - Pair? Pair, - bool IsDirectlyPaired, + string HashedCid, + string Name, + string? World, + nint Address, + Pair? Pair, + bool IsDirectlyPaired, List SharedSyncshells); private void DrawNearbyPlayerRow(NearbyPlayerData data) @@ -1096,8 +1096,8 @@ public class LightFinderUI : WindowMediatorSubscriberBase ImGui.SetCursorPosX(startX); _uiSharedService.IconText(FontAwesomeIcon.Users, UIColors.Get("LightlessPurple")); ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale); - var shellText = data.SharedSyncshells.Count == 1 - ? data.SharedSyncshells[0] + var shellText = data.SharedSyncshells.Count == 1 + ? data.SharedSyncshells[0] : $"{data.SharedSyncshells.Count} shared shells"; ImGui.TextColored(UIColors.Get("LightlessPurple"), shellText); if (data.SharedSyncshells.Count > 1 && ImGui.IsItemHovered()) @@ -1380,6 +1380,8 @@ public class LightFinderUI : WindowMediatorSubscriberBase ImGui.PopStyleVar(); } + #endregion + private void DrawDebugTab() { #if DEBUG @@ -1467,8 +1469,6 @@ public class LightFinderUI : WindowMediatorSubscriberBase #endif } - #endregion - #region Data Refresh private async Task RefreshSyncshellsAsync() @@ -1736,4 +1736,4 @@ public class LightFinderUI : WindowMediatorSubscriberBase } #endregion -} +} \ No newline at end of file -- 2.49.1 From 18fa0a47b1fff1493976fdf3007279a7af858eff Mon Sep 17 00:00:00 2001 From: Tsubasahane Date: Mon, 29 Dec 2025 15:42:55 +0800 Subject: [PATCH 16/87] Locationshare fix --- LightlessAPI | 2 +- .../Services/LocationShareService.cs | 9 ++++++- LightlessSync/UI/Components/DrawUserPair.cs | 24 +++++++++++-------- .../SignalR/ApIController.Functions.Users.cs | 6 ++--- 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/LightlessAPI b/LightlessAPI index 852e2a0..c3caa7e 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit 852e2a005f5bfdf3844e057c6ba71de6f5f84ed8 +Subproject commit c3caa7e25cf17fd52c4765bf051ec37c8fd92082 diff --git a/LightlessSync/Services/LocationShareService.cs b/LightlessSync/Services/LocationShareService.cs index 71989e5..77b5c90 100644 --- a/LightlessSync/Services/LocationShareService.cs +++ b/LightlessSync/Services/LocationShareService.cs @@ -126,6 +126,13 @@ namespace LightlessSync.Services throw; } } - + + public void UpdateSharingStatus(List users, DateTimeOffset expireAt) + { + foreach (var user in users) + { + AddStatus(user, expireAt); + } + } } } \ No newline at end of file diff --git a/LightlessSync/UI/Components/DrawUserPair.cs b/LightlessSync/UI/Components/DrawUserPair.cs index b8e58c4..3bd9fc0 100644 --- a/LightlessSync/UI/Components/DrawUserPair.cs +++ b/LightlessSync/UI/Components/DrawUserPair.cs @@ -251,9 +251,13 @@ public class DrawUserPair } } - private Task ToggleLocationSharing(List users, DateTimeOffset expireAt) + private async Task ToggleLocationSharing(List users, DateTimeOffset expireAt) { - return _apiController.ToggleLocationSharing(new LocationSharingToggleDto(users, expireAt)); + var updated = await _apiController.ToggleLocationSharing(new LocationSharingToggleDto(users, expireAt)).ConfigureAwait(false); + if (updated) + { + _locationShareService.UpdateSharingStatus(users, expireAt); + } } private void DrawIndividualMenu() @@ -617,25 +621,25 @@ public class DrawUserPair var location = _locationShareService.GetUserLocation(_pair.UserPair!.User.UID); var shareLocation = !string.IsNullOrEmpty(location); var expireAt = _locationShareService.GetSharingStatus(_pair.UserPair!.User.UID); - var shareLocationOther = expireAt > DateTimeOffset.UtcNow; + var shareLocationToOther = expireAt > DateTimeOffset.UtcNow; var shareColor = shareLocation switch { - true when shareLocationOther => UIColors.Get("LightlessGreen"), - false when shareLocationOther => UIColors.Get("LightlessBlue"), + true when shareLocationToOther => UIColors.Get("LightlessGreen"), + true when !shareLocationToOther => UIColors.Get("LightlessBlue"), _ => UIColors.Get("LightlessYellow"), }; - if (shareLocation || shareLocationOther) + if (shareLocation || shareLocationToOther) { currentRightSide -= (_uiSharedService.GetIconSize(shareLocationIcon).X + spacingX); ImGui.SameLine(currentRightSide); - using (ImRaii.PushColor(ImGuiCol.Text, shareColor, shareLocation || shareLocationOther)) + using (ImRaii.PushColor(ImGuiCol.Text, shareColor, shareLocation || shareLocationToOther)) _uiSharedService.IconText(shareLocationIcon); if (ImGui.IsItemHovered()) { ImGui.BeginTooltip(); - if (shareLocationOther) + if (shareLocation) { if (_pair.IsOnline) { @@ -661,12 +665,12 @@ public class DrawUserPair } ImGui.Separator(); - if (shareLocation) + if (shareLocationToOther) { ImGui.TextUnformatted("Sharing your location.ヾ(•ω•`)o"); if (expireAt != DateTimeOffset.MaxValue) { - ImGui.TextUnformatted("Expired at " + expireAt.ToLocalTime().ToString("g")); + ImGui.TextUnformatted("Expires at " + expireAt.ToLocalTime().ToString("g")); } } else diff --git a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs index fca42f3..37b91f9 100644 --- a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs +++ b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs @@ -211,10 +211,10 @@ public partial class ApiController if (!IsConnected) return ([],[]); return await _lightlessHub!.InvokeAsync<(List, List)>(nameof(RequestAllLocationInfo)).ConfigureAwait(false); } - public async Task ToggleLocationSharing(LocationSharingToggleDto dto) + public async Task ToggleLocationSharing(LocationSharingToggleDto dto) { - if (!IsConnected) return; - await _lightlessHub!.SendAsync(nameof(ToggleLocationSharing), dto).ConfigureAwait(false); + if (!IsConnected) return false; + return await _lightlessHub!.InvokeAsync(nameof(ToggleLocationSharing), dto).ConfigureAwait(false); } } #pragma warning restore MA0040 \ No newline at end of file -- 2.49.1 From f37fdefddd8318055b773d09333171ac5bab31a5 Mon Sep 17 00:00:00 2001 From: Tsubasahane Date: Mon, 29 Dec 2025 16:43:12 +0800 Subject: [PATCH 17/87] show icon correctly --- LightlessSync/UI/Components/DrawUserPair.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/LightlessSync/UI/Components/DrawUserPair.cs b/LightlessSync/UI/Components/DrawUserPair.cs index 3bd9fc0..021ad1a 100644 --- a/LightlessSync/UI/Components/DrawUserPair.cs +++ b/LightlessSync/UI/Components/DrawUserPair.cs @@ -219,8 +219,11 @@ public class DrawUserPair _ = _apiController.UserSetPairPermissions(new UserPermissionsDto(_pair.UserData, permissions)); } UiSharedService.AttachToolTip("Changes VFX sync permissions with this user." + (individual ? individualText : string.Empty)); - - if (ImGui.BeginMenu(FontAwesomeIcon.Globe.ToIconString() + " Toggle Location sharing")) + + ImGui.SetCursorPosX(10f); + _uiSharedService.IconText(FontAwesomeIcon.Globe); + ImGui.SameLine(); + if (ImGui.BeginMenu("Toggle Location sharing")) { if (ImGui.MenuItem("Share for 30 Mins")) { -- 2.49.1 From 91e60694ad4de7261dbbf645e08b09495acf4d53 Mon Sep 17 00:00:00 2001 From: Tsubasahane Date: Tue, 30 Dec 2025 11:20:12 +0800 Subject: [PATCH 18/87] triggers update when map changes --- LightlessSync/Services/DalamudUtilService.cs | 15 ++++++++++++++- LightlessSync/Services/LocationShareService.cs | 5 ++--- LightlessSync/Services/Mediator/Messages.cs | 1 + 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 96c78f3..03ed35d 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -60,6 +60,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber private string _lastGlobalBlockReason = string.Empty; private ushort _lastZone = 0; private ushort _lastWorldId = 0; + private uint _lastMapId = 0; private bool _sentBetweenAreas = false; private Lazy _cid; @@ -689,7 +690,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber var outside = houseMan->OutdoorTerritory; var house = outside->HouseId; location.WardId = house.WardIndex + 1u; - location.HouseId = (uint)houseMan->GetCurrentPlot() + 1; + //location.HouseId = (uint)houseMan->GetCurrentPlot() + 1; location.DivisionId = houseMan->GetCurrentDivision(); } //_logger.LogWarning(LocationToString(location)); @@ -1139,6 +1140,18 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber Mediator.Publish(new ZoneSwitchEndMessage()); Mediator.Publish(new ResumeScanMessage(nameof(ConditionFlag.BetweenAreas))); } + + //Map + if (!_sentBetweenAreas) + { + var mapid = _clientState.MapId; + if (mapid != _lastMapId) + { + _lastMapId = mapid; + Mediator.Publish(new MapChangedMessage(mapid)); + } + } + var localPlayer = _objectTable.LocalPlayer; if (localPlayer != null) diff --git a/LightlessSync/Services/LocationShareService.cs b/LightlessSync/Services/LocationShareService.cs index 77b5c90..38b2834 100644 --- a/LightlessSync/Services/LocationShareService.cs +++ b/LightlessSync/Services/LocationShareService.cs @@ -17,8 +17,6 @@ namespace LightlessSync.Services private IMemoryCache _sharingStatus = new MemoryCache(new MemoryCacheOptions()); private CancellationTokenSource _resetToken = new CancellationTokenSource(); - - public LocationShareService(ILogger logger, LightlessMediator mediator, DalamudUtilService dalamudUtilService, ApiController apiController) : base(logger, mediator) { _dalamudUtilService = dalamudUtilService; @@ -37,7 +35,7 @@ namespace LightlessSync.Services _ = RequestAllLocation(); } ); Mediator.Subscribe(this, UpdateLocationList); - Mediator.Subscribe(this, + Mediator.Subscribe(this, msg => _ = _apiController.UpdateLocation(new LocationDto(new UserData(_apiController.UID, _apiController.DisplayName), _dalamudUtilService.GetMapData()))); } @@ -134,5 +132,6 @@ namespace LightlessSync.Services AddStatus(user, expireAt); } } + } } \ No newline at end of file diff --git a/LightlessSync/Services/Mediator/Messages.cs b/LightlessSync/Services/Mediator/Messages.cs index 00f8de7..efe3341 100644 --- a/LightlessSync/Services/Mediator/Messages.cs +++ b/LightlessSync/Services/Mediator/Messages.cs @@ -136,5 +136,6 @@ public record ChatChannelMessageAdded(string ChannelKey, ChatMessageEntry Messag public record GroupCollectionChangedMessage : MessageBase; 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 \ No newline at end of file -- 2.49.1 From d1c955c74f28bd47bc3c277cd2a67ef83a0eb4d7 Mon Sep 17 00:00:00 2001 From: Tsubasahane Date: Tue, 30 Dec 2025 14:23:37 +0800 Subject: [PATCH 19/87] Reuse WorldData and make context menu work for non-Global uses --- LightlessSync/Services/ContextMenuService.cs | 48 ++++---------------- LightlessSync/Services/DalamudUtilService.cs | 2 +- 2 files changed, 9 insertions(+), 41 deletions(-) diff --git a/LightlessSync/Services/ContextMenuService.cs b/LightlessSync/Services/ContextMenuService.cs index 3fe893c..024e17b 100644 --- a/LightlessSync/Services/ContextMenuService.cs +++ b/LightlessSync/Services/ContextMenuService.cs @@ -10,7 +10,6 @@ using LightlessSync.UI; using LightlessSync.UI.Services; using LightlessSync.Utils; using LightlessSync.WebAPI; -using Lumina.Excel.Sheets; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -172,9 +171,8 @@ internal class ContextMenuService : IHostedService _logger.LogTrace("Cannot send pair request to {TargetName}@{World} while in PvP or GPose.", target.TargetName, target.TargetHomeWorld.RowId); return; } - - var world = GetWorld(target.TargetHomeWorld.RowId); - if (!IsWorldValid(world)) + + if (!IsWorldValid(target.TargetHomeWorld.RowId)) { _logger.LogTrace("Target player {TargetName}@{World} is on an invalid world.", target.TargetName, target.TargetHomeWorld.RowId); return; @@ -226,9 +224,8 @@ internal class ContextMenuService : IHostedService { if (args.Target is not MenuTargetDefault target) return; - - var world = GetWorld(target.TargetHomeWorld.RowId); - if (!IsWorldValid(world)) + + if (!target.TargetHomeWorld.IsValid || !IsWorldValid(target.TargetHomeWorld.RowId)) return; try @@ -237,7 +234,7 @@ internal class ContextMenuService : IHostedService if (targetData == null || targetData.Address == nint.Zero) { - _logger.LogWarning("Target player {TargetName}@{World} not found in object table.", target.TargetName, world.Name); + _logger.LogWarning("Target player {TargetName}@{World} not found in object table.", target.TargetName, target.TargetHomeWorld.Value.Name); return; } @@ -252,7 +249,7 @@ internal class ContextMenuService : IHostedService } // Notify in chat when NotificationService is disabled - NotifyInChat($"Pair request sent to {target.TargetName}@{world.Name}.", NotificationType.Info); + NotifyInChat($"Pair request sent to {target.TargetName}@{target.TargetHomeWorld.Value.Name}.", NotificationType.Info); } catch (Exception ex) { @@ -312,37 +309,8 @@ internal class ContextMenuService : IHostedService p.HomeWorld.RowId == target.TargetHomeWorld.RowId); } - private World GetWorld(uint worldId) + private bool IsWorldValid(uint worldId) { - var sheet = _gameData.GetExcelSheet()!; - var luminaWorlds = sheet.Where(x => - { - var dc = x.DataCenter.ValueNullable; - var name = x.Name.ExtractText(); - var internalName = x.InternalName.ExtractText(); - - if (dc == null || dc.Value.Region == 0 || string.IsNullOrWhiteSpace(dc.Value.Name.ExtractText())) - return false; - - if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(internalName)) - return false; - - if (name.Contains('-', StringComparison.Ordinal) || name.Contains('_', StringComparison.Ordinal)) - return false; - - return x.DataCenter.Value.Region != 5 || x.RowId > 3001 && x.RowId != 1200 && IsChineseJapaneseKoreanString(name); - }); - - return luminaWorlds.FirstOrDefault(x => x.RowId == worldId); - } - - private static bool IsChineseJapaneseKoreanString(string text) => text.All(IsChineseJapaneseKoreanCharacter); - - private static bool IsChineseJapaneseKoreanCharacter(char c) => c >= 0x4E00 && c <= 0x9FFF; - - public static bool IsWorldValid(World world) - { - var name = world.Name.ToString(); - return !string.IsNullOrWhiteSpace(name) && char.IsUpper(name[0]); + return _dalamudUtil.WorldData.Value.ContainsKey((ushort)worldId); } } diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 03ed35d..b278667 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -91,7 +91,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber { return gameData.GetExcelSheet(clientLanguage)! .Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0]) - || w is { RowId: > 1000, Region: 101 })) + || w is { RowId: > 1000, Region: 101 or 201 })) .ToDictionary(w => (ushort)w.RowId, w => w.Name.ToString()); }); JobData = new(() => -- 2.49.1 From f8752fcb4d1a87a737db702e2e251a5e1aefbdb0 Mon Sep 17 00:00:00 2001 From: Tsubasahane Date: Tue, 30 Dec 2025 14:37:13 +0800 Subject: [PATCH 20/87] changed kanmoji to show correctly --- LightlessSync/UI/Components/DrawUserPair.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/LightlessSync/UI/Components/DrawUserPair.cs b/LightlessSync/UI/Components/DrawUserPair.cs index 021ad1a..ac5c01a 100644 --- a/LightlessSync/UI/Components/DrawUserPair.cs +++ b/LightlessSync/UI/Components/DrawUserPair.cs @@ -659,18 +659,18 @@ public class DrawUserPair } else { - ImGui.TextUnformatted("User not onlineㄟ( ▔, ▔ )ㄏ"); + ImGui.TextUnformatted("User not online. (´・ω・`)?"); } } else { - ImGui.TextUnformatted("NOT Sharing location with you.(⊙x⊙;)"); + ImGui.TextUnformatted("NOT Sharing location with you. o(TヘTo)"); } ImGui.Separator(); if (shareLocationToOther) { - ImGui.TextUnformatted("Sharing your location.ヾ(•ω•`)o"); + ImGui.TextUnformatted("Sharing your location. ヾ(•ω•`)o"); if (expireAt != DateTimeOffset.MaxValue) { ImGui.TextUnformatted("Expires at " + expireAt.ToLocalTime().ToString("g")); @@ -678,7 +678,7 @@ public class DrawUserPair } else { - ImGui.TextUnformatted("NOT sharing your location.(´。_。`)"); + ImGui.TextUnformatted("NOT sharing your location.  ̄へ ̄"); } ImGui.EndTooltip(); } -- 2.49.1 From ca7375b9c3f82eaea2089dcf636b5acffd01bf77 Mon Sep 17 00:00:00 2001 From: Tsubasahane Date: Tue, 30 Dec 2025 14:42:02 +0800 Subject: [PATCH 21/87] dont check location when target is offline --- LightlessSync/UI/Components/DrawUserPair.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/LightlessSync/UI/Components/DrawUserPair.cs b/LightlessSync/UI/Components/DrawUserPair.cs index ac5c01a..c8725e2 100644 --- a/LightlessSync/UI/Components/DrawUserPair.cs +++ b/LightlessSync/UI/Components/DrawUserPair.cs @@ -642,9 +642,10 @@ public class DrawUserPair if (ImGui.IsItemHovered()) { ImGui.BeginTooltip(); - if (shareLocation) + + if (_pair.IsOnline) { - if (_pair.IsOnline) + if (shareLocation) { if (!string.IsNullOrEmpty(location)) { @@ -659,12 +660,12 @@ public class DrawUserPair } else { - ImGui.TextUnformatted("User not online. (´・ω・`)?"); + ImGui.TextUnformatted("NOT Sharing location with you. o(TヘTo)"); } } else { - ImGui.TextUnformatted("NOT Sharing location with you. o(TヘTo)"); + ImGui.TextUnformatted("User not online. (´・ω・`)?"); } ImGui.Separator(); -- 2.49.1 From e25979e08991030fbfca121a9341b8c15d84d3f9 Mon Sep 17 00:00:00 2001 From: Tsubasahane Date: Tue, 30 Dec 2025 18:04:54 +0800 Subject: [PATCH 22/87] fix Icon direction --- LightlessSync/UI/DataAnalysisUi.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessSync/UI/DataAnalysisUi.cs b/LightlessSync/UI/DataAnalysisUi.cs index 32245d2..a3061a7 100644 --- a/LightlessSync/UI/DataAnalysisUi.cs +++ b/LightlessSync/UI/DataAnalysisUi.cs @@ -2183,7 +2183,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase bool toggleClicked = false; if (showToggle) { - var icon = isCollapsed ? FontAwesomeIcon.ChevronRight : FontAwesomeIcon.ChevronLeft; + var icon = !isCollapsed ? FontAwesomeIcon.ChevronRight : FontAwesomeIcon.ChevronLeft; Vector2 iconSize; using (_uiSharedService.IconFont.Push()) { -- 2.49.1 From bbb337566117efd13e23bee052075de5e2ca4892 Mon Sep 17 00:00:00 2001 From: defnotken Date: Wed, 31 Dec 2025 02:44:31 +0000 Subject: [PATCH 23/87] 2.0.3 staaato --- LightlessSync/LightlessSync.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 707d2a3..f04696c 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -3,7 +3,7 @@ - 2.0.2 + 2.0.3 https://github.com/Light-Public-Syncshells/LightlessClient -- 2.49.1 From fb58d8657d251ca3493fe456f992c8da1ef5f537 Mon Sep 17 00:00:00 2001 From: defnotken Date: Tue, 30 Dec 2025 23:43:22 -0600 Subject: [PATCH 24/87] Lifestream IPC witrh Debug Example --- .../Enums/ResidentialAetheryteKind.cs | 10 ++ .../Interop/InteropModel/GlobalModels.cs | 1 + .../Interop/Ipc/IpcCallerLifestream.cs | 93 +++++++++++++++++++ LightlessSync/Interop/Ipc/IpcManager.cs | 7 +- LightlessSync/Plugin.cs | 9 +- LightlessSync/UI/SettingsUi.cs | 32 +++++++ LightlessSync/UI/UISharedService.cs | 6 ++ 7 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 LightlessSync/Interop/InteropModel/Enums/ResidentialAetheryteKind.cs create mode 100644 LightlessSync/Interop/InteropModel/GlobalModels.cs create mode 100644 LightlessSync/Interop/Ipc/IpcCallerLifestream.cs diff --git a/LightlessSync/Interop/InteropModel/Enums/ResidentialAetheryteKind.cs b/LightlessSync/Interop/InteropModel/Enums/ResidentialAetheryteKind.cs new file mode 100644 index 0000000..af7c18e --- /dev/null +++ b/LightlessSync/Interop/InteropModel/Enums/ResidentialAetheryteKind.cs @@ -0,0 +1,10 @@ +namespace Lifestream.Enums; + +public enum ResidentialAetheryteKind +{ + Uldah = 9, + Gridania = 2, + Limsa = 8, + Foundation = 70, + Kugane = 111, +} \ No newline at end of file diff --git a/LightlessSync/Interop/InteropModel/GlobalModels.cs b/LightlessSync/Interop/InteropModel/GlobalModels.cs new file mode 100644 index 0000000..a02cbc6 --- /dev/null +++ b/LightlessSync/Interop/InteropModel/GlobalModels.cs @@ -0,0 +1 @@ +global using AddressBookEntryTuple = (string Name, int World, int City, int Ward, int PropertyType, int Plot, int Apartment, bool ApartmentSubdivision, bool AliasEnabled, string Alias); \ No newline at end of file diff --git a/LightlessSync/Interop/Ipc/IpcCallerLifestream.cs b/LightlessSync/Interop/Ipc/IpcCallerLifestream.cs new file mode 100644 index 0000000..2ced314 --- /dev/null +++ b/LightlessSync/Interop/Ipc/IpcCallerLifestream.cs @@ -0,0 +1,93 @@ +using Dalamud.Plugin; +using Dalamud.Plugin.Ipc; +using LightlessSync.Interop.Ipc.Framework; +using LightlessSync.Services.Mediator; +using Microsoft.Extensions.Logging; + + +namespace LightlessSync.Interop.Ipc; + +public sealed class IpcCallerLifestream : IpcServiceBase +{ + private static readonly IpcServiceDescriptor LifestreamDescriptor = new("Lifestream", "Lifestream", new Version(0, 0, 0, 0)); + + private readonly ICallGateSubscriber _executeLifestreamCommand; + private readonly ICallGateSubscriber _isHere; + private readonly ICallGateSubscriber _goToHousingAddress; + private readonly ICallGateSubscriber _isBusy; + private readonly ICallGateSubscriber _abort; + private readonly ICallGateSubscriber _changeWorld; + private readonly ICallGateSubscriber _changeWorldById; + private readonly ICallGateSubscriber _aetheryteTeleport; + private readonly ICallGateSubscriber _aetheryteTeleportById; + + public IpcCallerLifestream(IDalamudPluginInterface pi, LightlessMediator lightlessMediator, ILogger logger) + : base(logger, lightlessMediator, pi, LifestreamDescriptor) + { + _executeLifestreamCommand = pi.GetIpcSubscriber("Lifestream.ExecuteCommand"); + _isHere = pi.GetIpcSubscriber("Lifestream.IsHere"); + _goToHousingAddress = pi.GetIpcSubscriber("Lifestream.GoToHousingAddress"); + _isBusy = pi.GetIpcSubscriber("Lifestream.IsBusy"); + _abort = pi.GetIpcSubscriber("Lifestream.Abort"); + _changeWorld = pi.GetIpcSubscriber("Lifestream.ChangeWorld"); + _changeWorldById = pi.GetIpcSubscriber("Lifestream.ChangeWorldById"); + _aetheryteTeleport = pi.GetIpcSubscriber("Lifestream.AetheryteTeleport"); + _aetheryteTeleportById = pi.GetIpcSubscriber("Lifestream.AetheryteTeleportById"); + CheckAPI(); + } + + public void ExecuteLifestreamCommand(string command) + { + if (!APIAvailable) return; + _executeLifestreamCommand.InvokeAction(command); + } + + public bool IsHere(AddressBookEntryTuple entry) + { + if (!APIAvailable) return false; + return _isHere.InvokeFunc(entry); + } + + public void GoToHousingAddress(AddressBookEntryTuple entry) + { + if (!APIAvailable) return; + _goToHousingAddress.InvokeAction(entry); + } + + public bool IsBusy() + { + if (!APIAvailable) return false; + return _isBusy.InvokeFunc(); + } + + public void Abort() + { + if (!APIAvailable) return; + _abort.InvokeAction(); + } + + public bool ChangeWorld(string worldName) + { + if (!APIAvailable) return false; + return _changeWorld.InvokeFunc(worldName); + } + + public bool AetheryteTeleport(string aetheryteName) + { + if (!APIAvailable) return false; + return _aetheryteTeleport.InvokeFunc(aetheryteName); + } + + public bool ChangeWorldById(uint worldId) + { + if (!APIAvailable) return false; + return _changeWorldById.InvokeFunc(worldId); + } + + + public bool AetheryteTeleportById(uint aetheryteId) + { + if (!APIAvailable) return false; + return _aetheryteTeleportById.InvokeFunc(aetheryteId); + } +} \ No newline at end of file diff --git a/LightlessSync/Interop/Ipc/IpcManager.cs b/LightlessSync/Interop/Ipc/IpcManager.cs index 59d17c7..f77b084 100644 --- a/LightlessSync/Interop/Ipc/IpcManager.cs +++ b/LightlessSync/Interop/Ipc/IpcManager.cs @@ -7,7 +7,8 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase { public IpcManager(ILogger logger, LightlessMediator mediator, IpcCallerPenumbra penumbraIpc, IpcCallerGlamourer glamourerIpc, IpcCallerCustomize customizeIpc, IpcCallerHeels heelsIpc, - IpcCallerHonorific honorificIpc, IpcCallerMoodles moodlesIpc, IpcCallerPetNames ipcCallerPetNames, IpcCallerBrio ipcCallerBrio) : base(logger, mediator) + IpcCallerHonorific honorificIpc, IpcCallerMoodles moodlesIpc, IpcCallerPetNames ipcCallerPetNames, IpcCallerBrio ipcCallerBrio, + IpcCallerLifestream ipcCallerLifestream) : base(logger, mediator) { CustomizePlus = customizeIpc; Heels = heelsIpc; @@ -17,6 +18,7 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase Moodles = moodlesIpc; PetNames = ipcCallerPetNames; Brio = ipcCallerBrio; + Lifestream = ipcCallerLifestream; if (Initialized) { @@ -44,8 +46,8 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase public IpcCallerPenumbra Penumbra { get; } public IpcCallerMoodles Moodles { get; } public IpcCallerPetNames PetNames { get; } - public IpcCallerBrio Brio { get; } + public IpcCallerLifestream Lifestream { get; } private void PeriodicApiStateCheck() { @@ -58,5 +60,6 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase Moodles.CheckAPI(); PetNames.CheckAPI(); Brio.CheckAPI(); + Lifestream.CheckAPI(); } } \ No newline at end of file diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 2d46b43..e0b31b9 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -372,6 +372,11 @@ public sealed class Plugin : IDalamudPlugin sp.GetRequiredService(), sp.GetRequiredService())); + services.AddSingleton(sp => new IpcCallerLifestream( + pluginInterface, + sp.GetRequiredService(), + sp.GetRequiredService>())); + services.AddSingleton(sp => new IpcManager( sp.GetRequiredService>(), sp.GetRequiredService(), @@ -382,7 +387,9 @@ public sealed class Plugin : IDalamudPlugin sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService() + )); // Notifications / HTTP services.AddSingleton(sp => new NotificationService( diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 1c86580..365b010 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -5,6 +5,7 @@ using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Utility; +using Lifestream.Enums; using LightlessSync.API.Data; using LightlessSync.API.Data.Comparer; using LightlessSync.API.Data.Enum; @@ -1259,6 +1260,37 @@ public class SettingsUi : WindowMediatorSubscriberBase UiSharedService.AttachToolTip("Use this when reporting mods being rejected from the server."); + if (_uiShared.IconTextButton(FontAwesomeIcon.Home, "Teleport to Limsa [LIFESTREAM TEST]")) + { + _ipcManager.Lifestream.ExecuteLifestreamCommand("limsa"); + } + + if (_uiShared.IconTextButton(FontAwesomeIcon.Home, "Teleport to JoyHouse [LIFESTREAM TEST]")) + { + var twintania = _dalamudUtilService.WorldData.Value + .FirstOrDefault(kvp => kvp.Value.Equals("Twintania", StringComparison.OrdinalIgnoreCase)); + + int ward = 29; + int plot = 7; + + AddressBookEntryTuple addressEntry = ( + Name: "", + World: (int)twintania.Key, + City: (int)ResidentialAetheryteKind.Kugane, + Ward: ward, + PropertyType: 0, + Plot: plot, + Apartment: 1, + ApartmentSubdivision: false, + AliasEnabled: false, + Alias: "" + ); + + _logger.LogInformation("going to: {address}", addressEntry); + + _ipcManager.Lifestream.GoToHousingAddress(addressEntry); + } + _uiShared.DrawCombo("Log Level", Enum.GetValues(), (l) => l.ToString(), (l) => { _configService.Current.LogLevel = l; diff --git a/LightlessSync/UI/UISharedService.cs b/LightlessSync/UI/UISharedService.cs index fc5225c..514f31e 100644 --- a/LightlessSync/UI/UISharedService.cs +++ b/LightlessSync/UI/UISharedService.cs @@ -79,6 +79,7 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase private readonly Dictionary _oauthTokenExpiry = []; private bool _penumbraExists = false; private bool _petNamesExists = false; + private bool _lifestreamExists = false; private int _serverSelectionIndex = -1; public UiSharedService(ILogger logger, IpcManager ipcManager, ApiController apiController, CacheMonitor cacheMonitor, FileDialogManager fileDialogManager, @@ -112,6 +113,7 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase _moodlesExists = _ipcManager.Moodles.APIAvailable; _petNamesExists = _ipcManager.PetNames.APIAvailable; _brioExists = _ipcManager.Brio.APIAvailable; + _lifestreamExists = _ipcManager.Lifestream.APIAvailable; }); UidFont = _pluginInterface.UiBuilder.FontAtlas.NewDelegateFontHandle(e => @@ -1105,6 +1107,10 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase ColorText("Brio", GetBoolColor(_brioExists)); AttachToolTip(BuildPluginTooltip("Brio", _brioExists, _ipcManager.Brio.State)); + ImGui.SameLine(); + ColorText("Lifestream", GetBoolColor(_lifestreamExists)); + AttachToolTip(BuildPluginTooltip("Lifestream", _lifestreamExists, _ipcManager.Lifestream.State)); + if (!_penumbraExists || !_glamourerExists) { ImGui.TextColored(ImGuiColors.DalamudRed, "You need to install both Penumbra and Glamourer and keep them up to date to use Lightless Sync."); -- 2.49.1 From 7e61954541e96414e5873351a9b49298b341ef6b Mon Sep 17 00:00:00 2001 From: Tsubasa Date: Wed, 31 Dec 2025 17:31:31 +0000 Subject: [PATCH 25/87] Location Sharing 2.0 (#125) Need: https://git.lightless-sync.org/Lightless-Sync/LightlessServer/pulls/49 Authored-by: Tsubasahane Reviewed-on: https://git.lightless-sync.org/Lightless-Sync/LightlessClient/pulls/125 Reviewed-by: cake Co-authored-by: Tsubasa Co-committed-by: Tsubasa --- LightlessAPI | 2 +- LightlessSync/LightlessSync.csproj | 1 + LightlessSync/Plugin.cs | 1 + LightlessSync/Services/ContextMenuService.cs | 48 +----- LightlessSync/Services/DalamudUtilService.cs | 31 +++- .../Services/LocationShareService.cs | 137 ++++++++++++++++++ LightlessSync/Services/Mediator/Messages.cs | 2 + LightlessSync/UI/Components/DrawUserPair.cs | 110 ++++++++++++++ LightlessSync/UI/DataAnalysisUi.cs | 2 +- LightlessSync/UI/DrawEntityFactory.cs | 4 + .../SignalR/ApIController.Functions.Users.cs | 16 ++ .../ApiController.Functions.Callbacks.cs | 13 ++ LightlessSync/WebAPI/SignalR/ApiController.cs | 2 + 13 files changed, 320 insertions(+), 49 deletions(-) create mode 100644 LightlessSync/Services/LocationShareService.cs diff --git a/LightlessAPI b/LightlessAPI index 5656600..4ecd537 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit 56566003e0e93bba05dcef49fd3ce23c6a204d81 +Subproject commit 4ecd5375e63082f44b841bcba38d5dd3f4a2a79b diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index f04696c..938d413 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -37,6 +37,7 @@ + diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 2d46b43..d070831 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -140,6 +140,7 @@ public sealed class Plugin : IDalamudPlugin services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => new TextureMetadataHelper(sp.GetRequiredService>(), gameData)); diff --git a/LightlessSync/Services/ContextMenuService.cs b/LightlessSync/Services/ContextMenuService.cs index 3fe893c..024e17b 100644 --- a/LightlessSync/Services/ContextMenuService.cs +++ b/LightlessSync/Services/ContextMenuService.cs @@ -10,7 +10,6 @@ using LightlessSync.UI; using LightlessSync.UI.Services; using LightlessSync.Utils; using LightlessSync.WebAPI; -using Lumina.Excel.Sheets; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -172,9 +171,8 @@ internal class ContextMenuService : IHostedService _logger.LogTrace("Cannot send pair request to {TargetName}@{World} while in PvP or GPose.", target.TargetName, target.TargetHomeWorld.RowId); return; } - - var world = GetWorld(target.TargetHomeWorld.RowId); - if (!IsWorldValid(world)) + + if (!IsWorldValid(target.TargetHomeWorld.RowId)) { _logger.LogTrace("Target player {TargetName}@{World} is on an invalid world.", target.TargetName, target.TargetHomeWorld.RowId); return; @@ -226,9 +224,8 @@ internal class ContextMenuService : IHostedService { if (args.Target is not MenuTargetDefault target) return; - - var world = GetWorld(target.TargetHomeWorld.RowId); - if (!IsWorldValid(world)) + + if (!target.TargetHomeWorld.IsValid || !IsWorldValid(target.TargetHomeWorld.RowId)) return; try @@ -237,7 +234,7 @@ internal class ContextMenuService : IHostedService if (targetData == null || targetData.Address == nint.Zero) { - _logger.LogWarning("Target player {TargetName}@{World} not found in object table.", target.TargetName, world.Name); + _logger.LogWarning("Target player {TargetName}@{World} not found in object table.", target.TargetName, target.TargetHomeWorld.Value.Name); return; } @@ -252,7 +249,7 @@ internal class ContextMenuService : IHostedService } // Notify in chat when NotificationService is disabled - NotifyInChat($"Pair request sent to {target.TargetName}@{world.Name}.", NotificationType.Info); + NotifyInChat($"Pair request sent to {target.TargetName}@{target.TargetHomeWorld.Value.Name}.", NotificationType.Info); } catch (Exception ex) { @@ -312,37 +309,8 @@ internal class ContextMenuService : IHostedService p.HomeWorld.RowId == target.TargetHomeWorld.RowId); } - private World GetWorld(uint worldId) + private bool IsWorldValid(uint worldId) { - var sheet = _gameData.GetExcelSheet()!; - var luminaWorlds = sheet.Where(x => - { - var dc = x.DataCenter.ValueNullable; - var name = x.Name.ExtractText(); - var internalName = x.InternalName.ExtractText(); - - if (dc == null || dc.Value.Region == 0 || string.IsNullOrWhiteSpace(dc.Value.Name.ExtractText())) - return false; - - if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(internalName)) - return false; - - if (name.Contains('-', StringComparison.Ordinal) || name.Contains('_', StringComparison.Ordinal)) - return false; - - return x.DataCenter.Value.Region != 5 || x.RowId > 3001 && x.RowId != 1200 && IsChineseJapaneseKoreanString(name); - }); - - return luminaWorlds.FirstOrDefault(x => x.RowId == worldId); - } - - private static bool IsChineseJapaneseKoreanString(string text) => text.All(IsChineseJapaneseKoreanCharacter); - - private static bool IsChineseJapaneseKoreanCharacter(char c) => c >= 0x4E00 && c <= 0x9FFF; - - public static bool IsWorldValid(World world) - { - var name = world.Name.ToString(); - return !string.IsNullOrWhiteSpace(name) && char.IsUpper(name[0]); + return _dalamudUtil.WorldData.Value.ContainsKey((ushort)worldId); } } diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 0b93997..b278667 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -1,11 +1,13 @@ using Dalamud.Game.ClientState.Conditions; using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Game.Text; using Dalamud.Plugin.Services; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.Game.Character; using FFXIVClientStructs.FFXIV.Client.Game.Control; +using FFXIVClientStructs.FFXIV.Client.Game.UI; using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; using FFXIVClientStructs.FFXIV.Client.UI.Agent; using LightlessSync.API.Dto.CharaData; @@ -26,6 +28,7 @@ using System.Text; using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject; +using Map = Lumina.Excel.Sheets.Map; using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags; namespace LightlessSync.Services; @@ -57,6 +60,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber private string _lastGlobalBlockReason = string.Empty; private ushort _lastZone = 0; private ushort _lastWorldId = 0; + private uint _lastMapId = 0; private bool _sentBetweenAreas = false; private Lazy _cid; @@ -86,7 +90,8 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber WorldData = new(() => { return gameData.GetExcelSheet(clientLanguage)! - .Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0]))) + .Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0]) + || w is { RowId: > 1000, Region: 101 or 201 })) .ToDictionary(w => (ushort)w.RowId, w => w.Name.ToString()); }); JobData = new(() => @@ -659,7 +664,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber var location = new LocationInfo(); location.ServerId = _playerState.CurrentWorld.RowId; - //location.InstanceId = UIState.Instance()->PublicInstance.InstanceId; //TODO:Need API update first + location.InstanceId = UIState.Instance()->PublicInstance.InstanceId; location.TerritoryId = _clientState.TerritoryType; location.MapId = _clientState.MapId; if (houseMan != null) @@ -685,7 +690,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber var outside = houseMan->OutdoorTerritory; var house = outside->HouseId; location.WardId = house.WardIndex + 1u; - location.HouseId = (uint)houseMan->GetCurrentPlot() + 1; + //location.HouseId = (uint)houseMan->GetCurrentPlot() + 1; location.DivisionId = houseMan->GetCurrentDivision(); } //_logger.LogWarning(LocationToString(location)); @@ -713,10 +718,10 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber str += $" - {MapData.Value[(ushort)location.MapId].MapName}"; } - // if (location.InstanceId is not 0) - // { - // str += ((SeIconChar)(57520 + location.InstanceId)).ToIconString(); - // } + if (location.InstanceId is not 0) + { + str += ((SeIconChar)(57520 + location.InstanceId)).ToIconString(); + } if (location.WardId is not 0) { @@ -1135,6 +1140,18 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber Mediator.Publish(new ZoneSwitchEndMessage()); Mediator.Publish(new ResumeScanMessage(nameof(ConditionFlag.BetweenAreas))); } + + //Map + if (!_sentBetweenAreas) + { + var mapid = _clientState.MapId; + if (mapid != _lastMapId) + { + _lastMapId = mapid; + Mediator.Publish(new MapChangedMessage(mapid)); + } + } + var localPlayer = _objectTable.LocalPlayer; if (localPlayer != null) diff --git a/LightlessSync/Services/LocationShareService.cs b/LightlessSync/Services/LocationShareService.cs new file mode 100644 index 0000000..38b2834 --- /dev/null +++ b/LightlessSync/Services/LocationShareService.cs @@ -0,0 +1,137 @@ +using LightlessSync.API.Data; +using LightlessSync.API.Dto.CharaData; +using LightlessSync.API.Dto.User; +using LightlessSync.Services.Mediator; +using LightlessSync.WebAPI; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; + +namespace LightlessSync.Services +{ + public class LocationShareService : DisposableMediatorSubscriberBase + { + private readonly DalamudUtilService _dalamudUtilService; + private readonly ApiController _apiController; + private IMemoryCache _locations = new MemoryCache(new MemoryCacheOptions()); + private IMemoryCache _sharingStatus = new MemoryCache(new MemoryCacheOptions()); + private CancellationTokenSource _resetToken = new CancellationTokenSource(); + + public LocationShareService(ILogger logger, LightlessMediator mediator, DalamudUtilService dalamudUtilService, ApiController apiController) : base(logger, mediator) + { + _dalamudUtilService = dalamudUtilService; + _apiController = apiController; + + + Mediator.Subscribe(this, (msg) => + { + _resetToken.Cancel(); + _resetToken.Dispose(); + _resetToken = new CancellationTokenSource(); + }); + Mediator.Subscribe(this, (msg) => + { + _ = _apiController.UpdateLocation(new LocationDto(new UserData(_apiController.UID, apiController.DisplayName), _dalamudUtilService.GetMapData())); + _ = RequestAllLocation(); + } ); + Mediator.Subscribe(this, UpdateLocationList); + Mediator.Subscribe(this, + msg => _ = _apiController.UpdateLocation(new LocationDto(new UserData(_apiController.UID, _apiController.DisplayName), _dalamudUtilService.GetMapData()))); + } + + private void UpdateLocationList(LocationSharingMessage msg) + { + if (_locations.TryGetValue(msg.User.UID, out _) && msg.LocationInfo.ServerId is 0) + { + _locations.Remove(msg.User.UID); + return; + } + + if ( msg.LocationInfo.ServerId is not 0 && msg.ExpireAt > DateTime.UtcNow) + { + AddLocationInfo(msg.User.UID, msg.LocationInfo, msg.ExpireAt); + } + } + + private void AddLocationInfo(string uid, LocationInfo location, DateTimeOffset expireAt) + { + var options = new MemoryCacheEntryOptions() + .SetAbsoluteExpiration(expireAt) + .AddExpirationToken(new CancellationChangeToken(_resetToken.Token)); + _locations.Set(uid, location, options); + } + + private async Task RequestAllLocation() + { + try + { + var (data, status) = await _apiController.RequestAllLocationInfo().ConfigureAwait(false); + foreach (var dto in data) + { + AddLocationInfo(dto.LocationDto.User.UID, dto.LocationDto.Location, dto.ExpireAt); + } + + foreach (var dto in status) + { + AddStatus(dto.User.UID, dto.ExpireAt); + } + } + catch (Exception e) + { + Logger.LogError(e,"RequestAllLocation error : "); + throw; + } + } + + private void AddStatus(string uid, DateTimeOffset expireAt) + { + var options = new MemoryCacheEntryOptions() + .SetAbsoluteExpiration(expireAt) + .AddExpirationToken(new CancellationChangeToken(_resetToken.Token)); + _sharingStatus.Set(uid, expireAt, options); + } + + public string GetUserLocation(string uid) + { + try + { + if (_locations.TryGetValue(uid, out var location)) + { + return _dalamudUtilService.LocationToString(location); + } + return String.Empty; + } + catch (Exception e) + { + Logger.LogError(e,"GetUserLocation error : "); + throw; + } + } + + public DateTimeOffset GetSharingStatus(string uid) + { + try + { + if (_sharingStatus.TryGetValue(uid, out var expireAt)) + { + return expireAt; + } + return DateTimeOffset.MinValue; + } + catch (Exception e) + { + Logger.LogError(e,"GetSharingStatus error : "); + throw; + } + } + + public void UpdateSharingStatus(List users, DateTimeOffset expireAt) + { + foreach (var user in users) + { + AddStatus(user, expireAt); + } + } + + } +} \ No newline at end of file diff --git a/LightlessSync/Services/Mediator/Messages.cs b/LightlessSync/Services/Mediator/Messages.cs index 758b9f5..efe3341 100644 --- a/LightlessSync/Services/Mediator/Messages.cs +++ b/LightlessSync/Services/Mediator/Messages.cs @@ -135,5 +135,7 @@ public record ChatChannelsUpdated : MessageBase; public record ChatChannelMessageAdded(string ChannelKey, ChatMessageEntry Message) : MessageBase; public record GroupCollectionChangedMessage : MessageBase; 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 \ No newline at end of file diff --git a/LightlessSync/UI/Components/DrawUserPair.cs b/LightlessSync/UI/Components/DrawUserPair.cs index a5fa953..c8725e2 100644 --- a/LightlessSync/UI/Components/DrawUserPair.cs +++ b/LightlessSync/UI/Components/DrawUserPair.cs @@ -37,6 +37,7 @@ public class DrawUserPair private readonly UiSharedService _uiSharedService; private readonly PlayerPerformanceConfigService _performanceConfigService; private readonly LightlessConfigService _configService; + private readonly LocationShareService _locationShareService; private readonly CharaDataManager _charaDataManager; private readonly PairLedger _pairLedger; private float _menuWidth = -1; @@ -57,6 +58,7 @@ public class DrawUserPair UiSharedService uiSharedService, PlayerPerformanceConfigService performanceConfigService, LightlessConfigService configService, + LocationShareService locationShareService, CharaDataManager charaDataManager, PairLedger pairLedger) { @@ -74,6 +76,7 @@ public class DrawUserPair _uiSharedService = uiSharedService; _performanceConfigService = performanceConfigService; _configService = configService; + _locationShareService = locationShareService; _charaDataManager = charaDataManager; _pairLedger = pairLedger; } @@ -216,6 +219,48 @@ public class DrawUserPair _ = _apiController.UserSetPairPermissions(new UserPermissionsDto(_pair.UserData, permissions)); } UiSharedService.AttachToolTip("Changes VFX sync permissions with this user." + (individual ? individualText : string.Empty)); + + ImGui.SetCursorPosX(10f); + _uiSharedService.IconText(FontAwesomeIcon.Globe); + ImGui.SameLine(); + if (ImGui.BeginMenu("Toggle Location sharing")) + { + if (ImGui.MenuItem("Share for 30 Mins")) + { + _ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.UtcNow.AddMinutes(30)); + } + + if (ImGui.MenuItem("Share for 1 Hour")) + { + _ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.UtcNow.AddHours(1)); + } + + if (ImGui.MenuItem("Share for 3 Hours")) + { + _ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.UtcNow.AddHours(3)); + } + + if (ImGui.MenuItem("Share until manually stop")) + { + _ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.MaxValue); + } + + ImGui.Separator(); + if (ImGui.MenuItem("Stop Sharing")) + { + _ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.MinValue); + } + ImGui.EndMenu(); + } + } + + private async Task ToggleLocationSharing(List users, DateTimeOffset expireAt) + { + var updated = await _apiController.ToggleLocationSharing(new LocationSharingToggleDto(users, expireAt)).ConfigureAwait(false); + if (updated) + { + _locationShareService.UpdateSharingStatus(users, expireAt); + } } private void DrawIndividualMenu() @@ -574,6 +619,71 @@ public class DrawUserPair var individualVFXDisabled = (_pair.UserPair?.OwnPermissions.IsDisableVFX() ?? false) || (_pair.UserPair?.OtherPermissions.IsDisableVFX() ?? false); var individualIsSticky = _pair.UserPair!.OwnPermissions.IsSticky(); var individualIcon = individualIsSticky ? FontAwesomeIcon.ArrowCircleUp : FontAwesomeIcon.InfoCircle; + + var shareLocationIcon = FontAwesomeIcon.Globe; + var location = _locationShareService.GetUserLocation(_pair.UserPair!.User.UID); + var shareLocation = !string.IsNullOrEmpty(location); + var expireAt = _locationShareService.GetSharingStatus(_pair.UserPair!.User.UID); + var shareLocationToOther = expireAt > DateTimeOffset.UtcNow; + var shareColor = shareLocation switch + { + true when shareLocationToOther => UIColors.Get("LightlessGreen"), + true when !shareLocationToOther => UIColors.Get("LightlessBlue"), + _ => UIColors.Get("LightlessYellow"), + }; + + if (shareLocation || shareLocationToOther) + { + currentRightSide -= (_uiSharedService.GetIconSize(shareLocationIcon).X + spacingX); + ImGui.SameLine(currentRightSide); + using (ImRaii.PushColor(ImGuiCol.Text, shareColor, shareLocation || shareLocationToOther)) + _uiSharedService.IconText(shareLocationIcon); + + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + + if (_pair.IsOnline) + { + if (shareLocation) + { + if (!string.IsNullOrEmpty(location)) + { + _uiSharedService.IconText(FontAwesomeIcon.LocationArrow); + ImGui.SameLine(); + ImGui.TextUnformatted(location); + } + else + { + ImGui.TextUnformatted("Location info not updated, reconnect or wait for update."); + } + } + else + { + ImGui.TextUnformatted("NOT Sharing location with you. o(TヘTo)"); + } + } + else + { + ImGui.TextUnformatted("User not online. (´・ω・`)?"); + } + ImGui.Separator(); + + if (shareLocationToOther) + { + ImGui.TextUnformatted("Sharing your location. ヾ(•ω•`)o"); + if (expireAt != DateTimeOffset.MaxValue) + { + ImGui.TextUnformatted("Expires at " + expireAt.ToLocalTime().ToString("g")); + } + } + else + { + ImGui.TextUnformatted("NOT sharing your location.  ̄へ ̄"); + } + ImGui.EndTooltip(); + } + } if (individualAnimDisabled || individualSoundsDisabled || individualVFXDisabled || individualIsSticky) { diff --git a/LightlessSync/UI/DataAnalysisUi.cs b/LightlessSync/UI/DataAnalysisUi.cs index 32245d2..a3061a7 100644 --- a/LightlessSync/UI/DataAnalysisUi.cs +++ b/LightlessSync/UI/DataAnalysisUi.cs @@ -2183,7 +2183,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase bool toggleClicked = false; if (showToggle) { - var icon = isCollapsed ? FontAwesomeIcon.ChevronRight : FontAwesomeIcon.ChevronLeft; + var icon = !isCollapsed ? FontAwesomeIcon.ChevronRight : FontAwesomeIcon.ChevronLeft; Vector2 iconSize; using (_uiSharedService.IconFont.Push()) { diff --git a/LightlessSync/UI/DrawEntityFactory.cs b/LightlessSync/UI/DrawEntityFactory.cs index 3c71f5c..e7bcc87 100644 --- a/LightlessSync/UI/DrawEntityFactory.cs +++ b/LightlessSync/UI/DrawEntityFactory.cs @@ -29,6 +29,7 @@ public class DrawEntityFactory private readonly LightlessConfigService _configService; private readonly UiSharedService _uiSharedService; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; + private readonly LocationShareService _locationShareService; private readonly CharaDataManager _charaDataManager; private readonly SelectTagForPairUi _selectTagForPairUi; private readonly RenamePairTagUi _renamePairTagUi; @@ -53,6 +54,7 @@ public class DrawEntityFactory LightlessConfigService configService, UiSharedService uiSharedService, PlayerPerformanceConfigService playerPerformanceConfigService, + LocationShareService locationShareService, CharaDataManager charaDataManager, SelectTagForSyncshellUi selectTagForSyncshellUi, RenameSyncshellTagUi renameSyncshellTagUi, @@ -72,6 +74,7 @@ public class DrawEntityFactory _configService = configService; _uiSharedService = uiSharedService; _playerPerformanceConfigService = playerPerformanceConfigService; + _locationShareService = locationShareService; _charaDataManager = charaDataManager; _selectTagForSyncshellUi = selectTagForSyncshellUi; _renameSyncshellTagUi = renameSyncshellTagUi; @@ -162,6 +165,7 @@ public class DrawEntityFactory _uiSharedService, _playerPerformanceConfigService, _configService, + _locationShareService, _charaDataManager, _pairLedger); } diff --git a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs index fdc4719..37b91f9 100644 --- a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs +++ b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs @@ -200,5 +200,21 @@ public partial class ApiController await UserPushData(new(visibleCharacters, character, censusDto)).ConfigureAwait(false); } + + public async Task UpdateLocation(LocationDto locationDto, bool offline = false) + { + if (!IsConnected) return; + await _lightlessHub!.SendAsync(nameof(UpdateLocation), locationDto, offline).ConfigureAwait(false); + } + public async Task<(List, List)> RequestAllLocationInfo() + { + if (!IsConnected) return ([],[]); + return await _lightlessHub!.InvokeAsync<(List, List)>(nameof(RequestAllLocationInfo)).ConfigureAwait(false); + } + public async Task ToggleLocationSharing(LocationSharingToggleDto dto) + { + if (!IsConnected) return false; + return await _lightlessHub!.InvokeAsync(nameof(ToggleLocationSharing), dto).ConfigureAwait(false); + } } #pragma warning restore MA0040 \ No newline at end of file diff --git a/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs b/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs index 490800f..d0a05f7 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs @@ -259,6 +259,13 @@ public partial class ApiController ExecuteSafely(() => Mediator.Publish(new GPoseLobbyReceiveWorldData(userData, worldData))); return Task.CompletedTask; } + + public Task Client_SendLocationToClient(LocationDto locationDto, DateTimeOffset expireAt) + { + Logger.LogDebug($"{nameof(Client_SendLocationToClient)}: {locationDto.User} {expireAt}"); + ExecuteSafely(() => Mediator.Publish(new LocationSharingMessage(locationDto.User, locationDto.Location, expireAt))); + return Task.CompletedTask; + } public void OnDownloadReady(Action act) { @@ -441,6 +448,12 @@ public partial class ApiController _lightlessHub!.On(nameof(Client_GposeLobbyPushWorldData), act); } + public void OnReceiveLocation(Action act) + { + if (_initialized) return; + _lightlessHub!.On(nameof(Client_SendLocationToClient), act); + } + private void ExecuteSafely(Action act) { try diff --git a/LightlessSync/WebAPI/SignalR/ApiController.cs b/LightlessSync/WebAPI/SignalR/ApiController.cs index 9639f6f..45705bf 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.cs @@ -606,6 +606,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL OnGposeLobbyPushCharacterData((dto) => _ = Client_GposeLobbyPushCharacterData(dto)); OnGposeLobbyPushPoseData((dto, data) => _ = Client_GposeLobbyPushPoseData(dto, data)); OnGposeLobbyPushWorldData((dto, data) => _ = Client_GposeLobbyPushWorldData(dto, data)); + OnReceiveLocation((dto, time) => _ = Client_SendLocationToClient(dto, time)); _healthCheckTokenSource?.Cancel(); _healthCheckTokenSource?.Dispose(); @@ -774,5 +775,6 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL ServerState = state; } + } #pragma warning restore MA0040 -- 2.49.1 From f812b6d09e864c8d2244b846ce443b097a1c0bbf Mon Sep 17 00:00:00 2001 From: choco Date: Thu, 1 Jan 2026 22:32:34 +0100 Subject: [PATCH 26/87] own syncshell sometimes not showing in list bug --- .../Services/LightFinder/LightFinderScannerService.cs | 8 ++++++-- LightlessSync/Services/LightFinder/LightFinderService.cs | 7 +++++-- LightlessSync/Services/Mediator/Messages.cs | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/LightlessSync/Services/LightFinder/LightFinderScannerService.cs b/LightlessSync/Services/LightFinder/LightFinderScannerService.cs index 38417a2..16de3c4 100644 --- a/LightlessSync/Services/LightFinder/LightFinderScannerService.cs +++ b/LightlessSync/Services/LightFinder/LightFinderScannerService.cs @@ -23,6 +23,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase private readonly HashSet _syncshellCids = []; private volatile bool _pendingLocalBroadcast; private TimeSpan? _pendingLocalTtl; + private string? _pendingLocalGid; private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4); private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1); @@ -175,6 +176,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase _pendingLocalBroadcast = true; _pendingLocalTtl = msg.Ttl; + _pendingLocalGid = msg.Gid; TryPrimeLocalBroadcastCache(); } @@ -190,11 +192,12 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase var expiry = DateTime.UtcNow + ttl; _broadcastCache.AddOrUpdate(localCid, - new BroadcastEntry(true, expiry, null), - (_, old) => new BroadcastEntry(true, expiry, old.GID)); + new BroadcastEntry(true, expiry, _pendingLocalGid), + (_, old) => new BroadcastEntry(true, expiry, _pendingLocalGid ?? old.GID)); _pendingLocalBroadcast = false; _pendingLocalTtl = null; + _pendingLocalGid = null; var now = DateTime.UtcNow; var activeCids = _broadcastCache @@ -204,6 +207,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase _lightFinderPlateHandler.UpdateBroadcastingCids(activeCids); _lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids); + UpdateSyncshellBroadcasts(); } private void UpdateSyncshellBroadcasts() diff --git a/LightlessSync/Services/LightFinder/LightFinderService.cs b/LightlessSync/Services/LightFinder/LightFinderService.cs index f07064c..b73dbed 100644 --- a/LightlessSync/Services/LightFinder/LightFinderService.cs +++ b/LightlessSync/Services/LightFinder/LightFinderService.cs @@ -1,4 +1,4 @@ -using Dalamud.Interface; +using Dalamud.Interface; using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.User; using LightlessSync.LightlessConfiguration; @@ -121,7 +121,10 @@ public class LightFinderService : IHostedService, IMediatorSubscriber _waitingForTtlFetch = false; if (!wasEnabled || previousRemaining != validTtl) - _mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl)); + { + var gid = _config.Current.SyncshellFinderEnabled ? _config.Current.SelectedFinderSyncshell : null; + _mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl, gid)); + } _logger.LogInformation("Lightfinder broadcast enabled ({Context}), TTL: {TTL}", context, validTtl); return true; diff --git a/LightlessSync/Services/Mediator/Messages.cs b/LightlessSync/Services/Mediator/Messages.cs index efe3341..ae74b78 100644 --- a/LightlessSync/Services/Mediator/Messages.cs +++ b/LightlessSync/Services/Mediator/Messages.cs @@ -123,7 +123,7 @@ public record GPoseLobbyReceivePoseData(UserData UserData, PoseData PoseData) : public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData) : MessageBase; public record OpenCharaDataHubWithFilterMessage(UserData UserData) : MessageBase; public record EnableBroadcastMessage(string HashedCid, bool Enabled) : MessageBase; -public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl) : MessageBase; +public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl, string? Gid = null) : MessageBase; public record UserLeftSyncshell(string gid) : MessageBase; public record UserJoinedSyncshell(string gid) : MessageBase; public record SyncshellBroadcastsUpdatedMessage : MessageBase; -- 2.49.1 From 906dda3885731135e1be7bea8e518e8b45c78fd1 Mon Sep 17 00:00:00 2001 From: choco Date: Thu, 1 Jan 2026 22:32:45 +0100 Subject: [PATCH 27/87] lightfinder nearby badge icon --- LightlessSync/UI/TopTabMenu.cs | 41 +++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/LightlessSync/UI/TopTabMenu.cs b/LightlessSync/UI/TopTabMenu.cs index 110b6c3..b8f08e7 100644 --- a/LightlessSync/UI/TopTabMenu.cs +++ b/LightlessSync/UI/TopTabMenu.cs @@ -170,8 +170,22 @@ public class TopTabMenu { Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); } + } - + var nearbyCount = GetNearbySyncshellCount(); + if (nearbyCount > 0) + { + var buttonMax = ImGui.GetItemRectMax(); + var badgeRadius = 8f * ImGuiHelpers.GlobalScale; + var badgeCenter = new Vector2(buttonMax.X - badgeRadius * 0.5f, buttonMax.Y - buttonSize.Y + badgeRadius * 0.5f); + var badgeText = nearbyCount > 99 ? "99+" : nearbyCount.ToString(); + var textSize = ImGui.CalcTextSize(badgeText); + + drawList.AddCircleFilled(badgeCenter, badgeRadius + 1f, ImGui.GetColorU32(new Vector4(0, 0, 0, 0.6f))); + drawList.AddCircleFilled(badgeCenter, badgeRadius, ImGui.GetColorU32(UIColors.Get("LightlessPurple"))); + + var textPos = new Vector2(badgeCenter.X - textSize.X * 0.5f, badgeCenter.Y - textSize.Y * 0.5f); + drawList.AddText(textPos, ImGui.GetColorU32(new Vector4(1, 1, 1, 1)), badgeText); } UiSharedService.AttachToolTip(GetLightfinderTooltip()); @@ -805,6 +819,31 @@ public class TopTabMenu return nearbyCount > 0 ? $"Lightfinder ({nearbyCount} nearby)" : "Open Lightfinder"; } + private int GetNearbySyncshellCount() + { + if (!_lightFinderService.IsBroadcasting) + return 0; + + string? myHashedCid = null; + try + { + var cid = _dalamudUtilService.GetCID(); + myHashedCid = cid.ToString().GetHash256(); + } + catch (Exception) + { + } + + return _lightFinderScannerService + .GetActiveSyncshellBroadcasts() + .Where(b => + !string.IsNullOrEmpty(b.GID) && + !string.Equals(b.HashedCID, myHashedCid, StringComparison.Ordinal)) + .Select(b => b.GID!) + .Distinct(StringComparer.Ordinal) + .Count(); + } + private void DrawUserConfig(float availableWidth, float spacingX) { var buttonX = (availableWidth - spacingX) / 2f; -- 2.49.1 From df33a0f0a2f8ab6e8bd92e7be0aeb7d6c54c6309 Mon Sep 17 00:00:00 2001 From: defnotken Date: Thu, 1 Jan 2026 17:27:12 -0600 Subject: [PATCH 28/87] Move buttons to debug --- LightlessSync/UI/SettingsUi.cs | 9 +++++---- LightlessSync/packages.lock.json | 23 ++++++++++++++++++++++- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 365b010..cb03cc5 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -1244,7 +1244,6 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.TreePop(); } -#endif if (_uiShared.IconTextButton(FontAwesomeIcon.Copy, "[DEBUG] Copy Last created Character Data to clipboard")) { if (LastCreatedCharacterData != null) @@ -1260,12 +1259,12 @@ public class SettingsUi : WindowMediatorSubscriberBase UiSharedService.AttachToolTip("Use this when reporting mods being rejected from the server."); - if (_uiShared.IconTextButton(FontAwesomeIcon.Home, "Teleport to Limsa [LIFESTREAM TEST]")) + if (_uiShared.IconTextButton(FontAwesomeIcon.Home, "Teleport to Limsa [LIFESTREAM TEST]") && _ipcManager.Lifestream.APIAvailable) { _ipcManager.Lifestream.ExecuteLifestreamCommand("limsa"); } - if (_uiShared.IconTextButton(FontAwesomeIcon.Home, "Teleport to JoyHouse [LIFESTREAM TEST]")) + if (_uiShared.IconTextButton(FontAwesomeIcon.Home, "Teleport to JoyHouse [LIFESTREAM TEST]") && _ipcManager.Lifestream.APIAvailable) { var twintania = _dalamudUtilService.WorldData.Value .FirstOrDefault(kvp => kvp.Value.Equals("Twintania", StringComparison.OrdinalIgnoreCase)); @@ -1281,7 +1280,7 @@ public class SettingsUi : WindowMediatorSubscriberBase PropertyType: 0, Plot: plot, Apartment: 1, - ApartmentSubdivision: false, + ApartmentSubdivision: false, AliasEnabled: false, Alias: "" ); @@ -1290,6 +1289,8 @@ public class SettingsUi : WindowMediatorSubscriberBase _ipcManager.Lifestream.GoToHousingAddress(addressEntry); } +#endif + _uiShared.DrawCombo("Log Level", Enum.GetValues(), (l) => l.ToString(), (l) => { diff --git a/LightlessSync/packages.lock.json b/LightlessSync/packages.lock.json index d47880c..45d7722 100644 --- a/LightlessSync/packages.lock.json +++ b/LightlessSync/packages.lock.json @@ -76,6 +76,19 @@ "Microsoft.AspNetCore.SignalR.Common": "10.0.1" } }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Direct", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "NxqSP0Ky4dZ5ybszdZCqs1X2C70s+dXflqhYBUh/vhcQVTIooNCXIYnLVbafoAFGZMs51d9+rHxveXs0ZC3SQQ==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, "Microsoft.Extensions.Hosting": { "type": "Direct", "requested": "[10.0.1, )", @@ -233,6 +246,14 @@ "Microsoft.AspNetCore.SignalR.Common": "10.0.1" } }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "Vb1vVAQDxHpXVdL9fpOX2BzeV7bbhzG4pAcIKRauRl0/VfkE8mq0f+fYC+gWICh3dlzTZInJ/cTeBS2MgU/XvQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, "Microsoft.Extensions.Configuration": { "type": "Transitive", "resolved": "10.0.1", @@ -618,7 +639,7 @@ "FlatSharp.Compiler": "[7.9.0, )", "FlatSharp.Runtime": "[7.9.0, )", "OtterGui": "[1.0.0, )", - "Penumbra.Api": "[5.13.0, )", + "Penumbra.Api": "[5.13.1, )", "Penumbra.String": "[1.0.7, )" } }, -- 2.49.1 From 5fba3c01e7d408eeea1b25d057cc8f782940674d Mon Sep 17 00:00:00 2001 From: choco Date: Fri, 2 Jan 2026 09:19:39 +0100 Subject: [PATCH 29/87] lightfinder nearby badge alignment --- LightlessSync/UI/TopTabMenu.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LightlessSync/UI/TopTabMenu.cs b/LightlessSync/UI/TopTabMenu.cs index b8f08e7..ae10dae 100644 --- a/LightlessSync/UI/TopTabMenu.cs +++ b/LightlessSync/UI/TopTabMenu.cs @@ -177,14 +177,14 @@ public class TopTabMenu { var buttonMax = ImGui.GetItemRectMax(); var badgeRadius = 8f * ImGuiHelpers.GlobalScale; - var badgeCenter = new Vector2(buttonMax.X - badgeRadius * 0.5f, buttonMax.Y - buttonSize.Y + badgeRadius * 0.5f); + var badgeCenter = new Vector2(buttonMax.X - badgeRadius * 1.3f, buttonMax.Y - buttonSize.Y + badgeRadius * 0.5f); var badgeText = nearbyCount > 99 ? "99+" : nearbyCount.ToString(); var textSize = ImGui.CalcTextSize(badgeText); drawList.AddCircleFilled(badgeCenter, badgeRadius + 1f, ImGui.GetColorU32(new Vector4(0, 0, 0, 0.6f))); drawList.AddCircleFilled(badgeCenter, badgeRadius, ImGui.GetColorU32(UIColors.Get("LightlessPurple"))); - var textPos = new Vector2(badgeCenter.X - textSize.X * 0.5f, badgeCenter.Y - textSize.Y * 0.5f); + var textPos = new Vector2(badgeCenter.X - textSize.X * 0.45f, badgeCenter.Y - textSize.Y * 0.55f); drawList.AddText(textPos, ImGui.GetColorU32(new Vector4(1, 1, 1, 1)), badgeText); } UiSharedService.AttachToolTip(GetLightfinderTooltip()); -- 2.49.1 From a4d62af73d9b6cb21d07dc0cae4bd593181e6ea0 Mon Sep 17 00:00:00 2001 From: choco Date: Fri, 2 Jan 2026 09:23:23 +0100 Subject: [PATCH 30/87] lightfinder user text --- LightlessSync/UI/LightFinderUI.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessSync/UI/LightFinderUI.cs b/LightlessSync/UI/LightFinderUI.cs index 0c0cad4..cad3d0b 100644 --- a/LightlessSync/UI/LightFinderUI.cs +++ b/LightlessSync/UI/LightFinderUI.cs @@ -1108,7 +1108,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase ImGui.SetCursorPosX(startX); _uiSharedService.IconText(FontAwesomeIcon.Wifi, UIColors.Get("LightlessBlue")); ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale); - ImGui.TextColored(ImGuiColors.DalamudGrey, "Lightfinder sir ma'am or whatever"); + ImGui.TextColored(ImGuiColors.DalamudGrey, "Lightfinder user"); } ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); -- 2.49.1 From f7bb73bcd1a03112118164d6a4af0afbb3747466 Mon Sep 17 00:00:00 2001 From: cake Date: Fri, 2 Jan 2026 18:34:07 +0100 Subject: [PATCH 31/87] Updated api --- LightlessAPI | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessAPI b/LightlessAPI index c3caa7e..4ecd537 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit c3caa7e25cf17fd52c4765bf051ec37c8fd92082 +Subproject commit 4ecd5375e63082f44b841bcba38d5dd3f4a2a79b -- 2.49.1 From 9ba45670c522131c05ec9a29bfc8e4a6b3b7303d Mon Sep 17 00:00:00 2001 From: choco Date: Sat, 3 Jan 2026 02:08:28 +0100 Subject: [PATCH 32/87] top menu cleanup, removed duplicate old code --- LightlessSync/UI/TopTabMenu.cs | 51 ++-------------------------------- 1 file changed, 3 insertions(+), 48 deletions(-) diff --git a/LightlessSync/UI/TopTabMenu.cs b/LightlessSync/UI/TopTabMenu.cs index ae10dae..4760713 100644 --- a/LightlessSync/UI/TopTabMenu.cs +++ b/LightlessSync/UI/TopTabMenu.cs @@ -187,7 +187,7 @@ public class TopTabMenu var textPos = new Vector2(badgeCenter.X - textSize.X * 0.45f, badgeCenter.Y - textSize.Y * 0.55f); drawList.AddText(textPos, ImGui.GetColorU32(new Vector4(1, 1, 1, 1)), badgeText); } - UiSharedService.AttachToolTip(GetLightfinderTooltip()); + UiSharedService.AttachToolTip(nearbyCount > 0 ? $"Lightfinder ({nearbyCount} nearby)" : "Open Lightfinder"); ImGui.SameLine(); using (ImRaii.PushFont(UiBuilder.IconFont)) @@ -781,58 +781,13 @@ public class TopTabMenu } } } - - private void DrawLightfinderMenu(float availableWidth, float spacingX) - { - var lightfinderLabel = GetLightfinderTooltip(); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.PersonCirclePlus, lightfinderLabel, availableWidth, center: true)) - { - _lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); - } - } - - private string GetLightfinderTooltip() - { - if (!_lightFinderService.IsBroadcasting) - return "Open Lightfinder"; - - string? myHashedCid = null; - try - { - var cid = _dalamudUtilService.GetCID(); - myHashedCid = cid.ToString().GetHash256(); - } - catch (Exception) - { - // Couldnt get own CID, log and return default table - } - - var nearbyCount = _lightFinderScannerService - .GetActiveSyncshellBroadcasts() - .Where(b => - !string.IsNullOrEmpty(b.GID) && - !string.Equals(b.HashedCID, myHashedCid, StringComparison.Ordinal)) - .Select(b => b.GID!) - .Distinct(StringComparer.Ordinal) - .Count(); - - return nearbyCount > 0 ? $"Lightfinder ({nearbyCount} nearby)" : "Open Lightfinder"; - } - + private int GetNearbySyncshellCount() { if (!_lightFinderService.IsBroadcasting) return 0; - string? myHashedCid = null; - try - { - var cid = _dalamudUtilService.GetCID(); - myHashedCid = cid.ToString().GetHash256(); - } - catch (Exception) - { - } + var myHashedCid = _dalamudUtilService.GetCID().ToString().GetHash256(); return _lightFinderScannerService .GetActiveSyncshellBroadcasts() -- 2.49.1 From deb7f67e593d0cc0d1d49a06ba4dcbfe872a7c5d Mon Sep 17 00:00:00 2001 From: cake Date: Sat, 3 Jan 2026 23:12:18 +0100 Subject: [PATCH 33/87] Added another try on fetching download status --- LightlessSync/UI/DownloadUi.cs | 53 +++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index 2d9cdc1..f898232 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -1,4 +1,5 @@ using Dalamud.Bindings.ImGui; +using Dalamud.Game.ClientState.Statuses; using Dalamud.Interface.Colors; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; @@ -167,22 +168,30 @@ public class DownloadUi : WindowMediatorSubscriberBase List>> transfers; try { - transfers = _currentDownloads.ToList(); + transfers = _currentDownloads?.ToList() ?? []; } catch (ArgumentException) { return; } - foreach (var transfer in transfers) { var transferKey = transfer.Key; - - // Skip if no valid game object - if (transferKey.GetGameObject() == null) + if (transferKey is null) continue; + var statusDict = transfer.Value; + if (statusDict is null) + continue; + + var gameObj = transferKey.GetGameObject(); + if (gameObj is null) + { + _smoothed.Remove(transferKey); + continue; + } + var rawPos = _dalamudUtilService.WorldToScreen(transferKey.GetGameObject()); // If RawPos is zero, remove it from smoothed dictionary @@ -207,25 +216,29 @@ public class DownloadUi : WindowMediatorSubscriberBase var dlProg = 0; var dlDecomp = 0; - foreach (var entry in transfer.Value) + try { - var fileStatus = entry.Value; - switch (fileStatus.DownloadStatus) + foreach (var entry in statusDict) { - case DownloadStatus.WaitingForSlot: - dlSlot++; - break; - case DownloadStatus.WaitingForQueue: - dlQueue++; - break; - case DownloadStatus.Downloading: - dlProg++; - break; - case DownloadStatus.Decompressing: - dlDecomp++; - break; + var fileStatus = entry.Value; + if (fileStatus is null) continue; + + totalBytes += fileStatus.TotalBytes; + transferredBytes += fileStatus.TransferredBytes; + + switch (fileStatus.DownloadStatus) + { + case DownloadStatus.WaitingForSlot: dlSlot++; break; + case DownloadStatus.WaitingForQueue: dlQueue++; break; + case DownloadStatus.Downloading: dlProg++; break; + case DownloadStatus.Decompressing: dlDecomp++; break; + } } } + catch (InvalidOperationException) + { + continue; + } string statusText; if (dlProg > 0) -- 2.49.1 From 3bbda696994f64d7af386867e87078ce844444e5 Mon Sep 17 00:00:00 2001 From: cake Date: Sat, 3 Jan 2026 23:22:18 +0100 Subject: [PATCH 34/87] Revert "Added another try on fetching download status" This reverts commit deb7f67e593d0cc0d1d49a06ba4dcbfe872a7c5d. --- LightlessSync/UI/DownloadUi.cs | 51 +++++++++++++--------------------- 1 file changed, 19 insertions(+), 32 deletions(-) diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index f898232..2d9cdc1 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -1,5 +1,4 @@ using Dalamud.Bindings.ImGui; -using Dalamud.Game.ClientState.Statuses; using Dalamud.Interface.Colors; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; @@ -168,30 +167,22 @@ public class DownloadUi : WindowMediatorSubscriberBase List>> transfers; try { - transfers = _currentDownloads?.ToList() ?? []; + transfers = _currentDownloads.ToList(); } catch (ArgumentException) { return; } + foreach (var transfer in transfers) { var transferKey = transfer.Key; - if (transferKey is null) - continue; - var statusDict = transfer.Value; - if (statusDict is null) + // Skip if no valid game object + if (transferKey.GetGameObject() == null) continue; - var gameObj = transferKey.GetGameObject(); - if (gameObj is null) - { - _smoothed.Remove(transferKey); - continue; - } - var rawPos = _dalamudUtilService.WorldToScreen(transferKey.GetGameObject()); // If RawPos is zero, remove it from smoothed dictionary @@ -216,29 +207,25 @@ public class DownloadUi : WindowMediatorSubscriberBase var dlProg = 0; var dlDecomp = 0; - try + foreach (var entry in transfer.Value) { - foreach (var entry in statusDict) + var fileStatus = entry.Value; + switch (fileStatus.DownloadStatus) { - var fileStatus = entry.Value; - if (fileStatus is null) continue; - - totalBytes += fileStatus.TotalBytes; - transferredBytes += fileStatus.TransferredBytes; - - switch (fileStatus.DownloadStatus) - { - case DownloadStatus.WaitingForSlot: dlSlot++; break; - case DownloadStatus.WaitingForQueue: dlQueue++; break; - case DownloadStatus.Downloading: dlProg++; break; - case DownloadStatus.Decompressing: dlDecomp++; break; - } + case DownloadStatus.WaitingForSlot: + dlSlot++; + break; + case DownloadStatus.WaitingForQueue: + dlQueue++; + break; + case DownloadStatus.Downloading: + dlProg++; + break; + case DownloadStatus.Decompressing: + dlDecomp++; + break; } } - catch (InvalidOperationException) - { - continue; - } string statusText; if (dlProg > 0) -- 2.49.1 From cd510f93af3bdc5bf700ffdf00e3049a09b320d0 Mon Sep 17 00:00:00 2001 From: cake Date: Sun, 4 Jan 2026 05:08:08 +0100 Subject: [PATCH 35/87] Changed banning into syncshell --- LightlessSync/Services/UiFactory.cs | 9 +- LightlessSync/UI/SyncshellAdminUI.cs | 438 +++++++++++++++++++++++---- 2 files changed, 384 insertions(+), 63 deletions(-) diff --git a/LightlessSync/Services/UiFactory.cs b/LightlessSync/Services/UiFactory.cs index 33ab3ae..cbc64f4 100644 --- a/LightlessSync/Services/UiFactory.cs +++ b/LightlessSync/Services/UiFactory.cs @@ -8,6 +8,7 @@ using LightlessSync.UI.Tags; using LightlessSync.WebAPI; using LightlessSync.UI.Services; using Microsoft.Extensions.Logging; +using LightlessSync.PlayerData.Factories; namespace LightlessSync.Services; @@ -23,6 +24,7 @@ public class UiFactory private readonly PerformanceCollectorService _performanceCollectorService; private readonly ProfileTagService _profileTagService; private readonly DalamudUtilService _dalamudUtilService; + private readonly PairFactory _pairFactory; public UiFactory( ILoggerFactory loggerFactory, @@ -34,7 +36,8 @@ public class UiFactory LightlessProfileManager lightlessProfileManager, PerformanceCollectorService performanceCollectorService, ProfileTagService profileTagService, - DalamudUtilService dalamudUtilService) + DalamudUtilService dalamudUtilService, + PairFactory pairFactory) { _loggerFactory = loggerFactory; _lightlessMediator = lightlessMediator; @@ -46,6 +49,7 @@ public class UiFactory _performanceCollectorService = performanceCollectorService; _profileTagService = profileTagService; _dalamudUtilService = dalamudUtilService; + _pairFactory = pairFactory; } public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto) @@ -58,7 +62,8 @@ public class UiFactory _pairUiService, dto, _performanceCollectorService, - _lightlessProfileManager); + _lightlessProfileManager, + _pairFactory); } public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair) diff --git a/LightlessSync/UI/SyncshellAdminUI.cs b/LightlessSync/UI/SyncshellAdminUI.cs index 526b5ae..eee8ab9 100644 --- a/LightlessSync/UI/SyncshellAdminUI.cs +++ b/LightlessSync/UI/SyncshellAdminUI.cs @@ -4,9 +4,11 @@ using Dalamud.Interface.Colors; using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; +using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.Group; +using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.Mediator; @@ -42,13 +44,32 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase private Task? _pruneTask; private int _pruneDays = 14; + // Ban management fields + private Task>? _bannedUsersTask; + private bool _bannedUsersLoaded; + private string? _bannedUsersLoadError; + + private string _newBanUid = string.Empty; + private string _newBanReason = string.Empty; + private Task? _newBanTask; + private string? _newBanError; + private DateTime _newBanBusyUntilUtc; + + // Ban editing fields + private string? _editingBanUid; + private readonly Dictionary _banReasonEdits = new(StringComparer.Ordinal); + + private Task? _banEditTask; + private string? _banEditError; + private Task? _pruneSettingsTask; private bool _pruneSettingsLoaded; private bool _autoPruneEnabled; private int _autoPruneDays = 14; + private readonly PairFactory _pairFactory; public SyncshellAdminUI(ILogger logger, LightlessMediator mediator, ApiController apiController, - UiSharedService uiSharedService, PairUiService pairUiService, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, LightlessProfileManager lightlessProfileManager) + UiSharedService uiSharedService, PairUiService pairUiService, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, LightlessProfileManager lightlessProfileManager, PairFactory pairFactory) : base(logger, mediator, "Syncshell Admin Panel (" + groupFullInfo.GroupAliasOrGID + ")", performanceCollectorService) { GroupFullInfo = groupFullInfo; @@ -76,6 +97,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase MaximumSize = new(700, 2000), }; _pairUiService = pairUiService; + _pairFactory = pairFactory; } public GroupFullInfoDto GroupFullInfo { get; private set; } @@ -654,34 +676,343 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase _uiSharedService.MediumText("User Bans", UIColors.Get("LightlessYellow")); ImGuiHelpers.ScaledDummy(3f); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Retweet, "Refresh Banlist from Server")) + EnsureBanListLoaded(); + + DrawNewBanEntryRow(); + + ImGuiHelpers.ScaledDummy(4f); + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Retweet, "Refresh Banlist")) { - _bannedUsers = _apiController.GroupGetBannedUsers(new GroupDto(GroupFullInfo.Group)).Result; + QueueBanListRefresh(force: true); } + ImGuiHelpers.ScaledDummy(2f); + if (!_bannedUsersLoaded) + { + UiSharedService.ColorTextWrapped("Loading banlist from server...", ImGuiColors.DalamudGrey); + return; + } + + if (!string.IsNullOrWhiteSpace(_bannedUsersLoadError)) + { + UiSharedService.ColorTextWrapped(_bannedUsersLoadError!, ImGuiColors.DalamudRed); + return; + } + ImGui.BeginChild("bannedListScroll#" + GroupFullInfo.GID, new Vector2(0, 0), true); var style = ImGui.GetStyle(); float fullW = ImGui.GetContentRegionAvail().X; + float scale = ImGuiHelpers.GlobalScale; + + float frame = ImGui.GetFrameHeight(); + float actionIcons = 3; + float colActions = actionIcons * frame + (actionIcons - 1) * style.ItemSpacing.X + 10f * scale; - float colIdentity = fullW * 0.45f; float colMeta = fullW * 0.35f; - float colActions = fullW - colIdentity - colMeta - style.ItemSpacing.X * 2.0f; - // Header + float colIdentity = fullW - colMeta - colActions - style.ItemSpacing.X * 2.0f; + + float minIdentity = fullW * 0.40f; + if (colIdentity < minIdentity) + { + colIdentity = minIdentity; + colMeta = fullW - colIdentity - colActions - style.ItemSpacing.X * 2.0f; + if (colMeta < 80f * scale) colMeta = 80f * scale; + } + DrawBannedListHeader(colIdentity, colMeta); int rowIndex = 0; foreach (var bannedUser in _bannedUsers.ToList()) { - // Each row DrawBannedRow(bannedUser, rowIndex++, colIdentity, colMeta, colActions); } ImGui.EndChild(); } + private void DrawNewBanEntryRow() + { + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessYellow")); + ImGui.TextUnformatted("Add new ban"); + ImGui.PopStyleColor(); + + UiSharedService.TextWrapped("Enter a UID and optional reason. (Hold CTRL to enable the ban button.)"); + + var style = ImGui.GetStyle(); + float fullW = ImGui.GetContentRegionAvail().X; + + float uidW = fullW * 0.35f; + float reasonW = fullW * 0.50f; + float btnW = fullW - uidW - reasonW - style.ItemSpacing.X * 2f; + + // UID + ImGui.SetNextItemWidth(uidW); + ImGui.InputTextWithHint("##newBanUid", "UID...", ref _newBanUid, 128); + + // Reason + ImGui.SameLine(0f, style.ItemSpacing.X); + ImGui.SetNextItemWidth(reasonW); + ImGui.InputTextWithHint("##newBanReason", "Reason (optional)...", ref _newBanReason, 256); + + // Ban button + ImGui.SameLine(0f, style.ItemSpacing.X); + + var trimmedUid = (_newBanUid ?? string.Empty).Trim(); + var now = DateTime.UtcNow; + bool taskRunning = _newBanTask != null && !_newBanTask.IsCompleted; + bool busyLatched = now < _newBanBusyUntilUtc; + bool busy = taskRunning || busyLatched; + + bool canBan = UiSharedService.CtrlPressed() + && !string.IsNullOrWhiteSpace(_newBanUid) + && !busy; + + using (ImRaii.Disabled(!canBan)) + using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("DimRed"))) + { + ImGui.SetNextItemWidth(btnW); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.UserSlash, "Ban")) + { + _newBanError = null; + + _newBanBusyUntilUtc = DateTime.UtcNow.AddMilliseconds(750); + + _newBanTask = SubmitNewBanByUidAsync(trimmedUid, _newBanReason); + } + } + UiSharedService.AttachToolTip("Hold CTRL to enable banning by UID."); + + if (busy) + { + UiSharedService.ColorTextWrapped("Banning user...", ImGuiColors.DalamudGrey); + } + + if (_newBanTask != null && _newBanTask.IsCompleted && DateTime.UtcNow >= _newBanBusyUntilUtc) + { + if (_newBanTask.IsFaulted) + { + var _ = _newBanTask.Exception; + _newBanError ??= "Ban failed (see log)."; + } + + _newBanTask = null; + } + } + + private async Task SubmitNewBanByUidAsync(string uidOrAlias, string reason) + { + try + { + await Task.Yield(); + + uidOrAlias = (uidOrAlias ?? string.Empty).Trim(); + reason = (reason ?? string.Empty).Trim(); + + if (string.IsNullOrWhiteSpace(uidOrAlias)) + { + _newBanError = "UID is empty."; + return; + } + + string targetUid = uidOrAlias; + string? typedAlias = null; + + // Try to resolve alias to UID if applicable + var snap = _pairUiService.GetSnapshot(); + if (snap.GroupPairs.TryGetValue(GroupFullInfo, out var pairs)) + { + var match = pairs.FirstOrDefault(p => + string.Equals(p.UserData.UID, uidOrAlias, StringComparison.Ordinal) || + string.Equals(p.UserData.AliasOrUID, uidOrAlias, StringComparison.OrdinalIgnoreCase)); + + if (match != null) + { + targetUid = match.UserData.UID; + typedAlias = match.UserData.Alias; + } + else + { + typedAlias = null; + } + } + + var userData = new UserData(UID: targetUid, Alias: typedAlias); + + await _apiController + .GroupBanUser(new GroupPairDto(GroupFullInfo.Group, userData), reason) + .ConfigureAwait(false); + + _newBanUid = string.Empty; + _newBanReason = string.Empty; + _newBanError = null; + + QueueBanListRefresh(force: true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to ban '{uidOrAlias}' in group {gid}", uidOrAlias, GroupFullInfo.Group.GID); + _newBanError = "Failed to ban user (see log)."; + } + } + + private async Task SaveBanReasonViaBanUserAsync(string uid) + { + try + { + if (!_banReasonEdits.TryGetValue(uid, out var newReason)) + newReason = string.Empty; + + newReason = (newReason ?? string.Empty).Trim(); + + var userData = new UserData(uid.Trim()); + + await _apiController + .GroupBanUser(new GroupPairDto(GroupFullInfo.Group, userData), newReason) + .ConfigureAwait(false); + + _editingBanUid = null; + _banEditError = null; + + QueueBanListRefresh(force: true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to edit ban reason for {uid} in group {gid}", uid, GroupFullInfo.Group.GID); + _banEditError = "Failed to update reason (see log)."; + } + } + + private void DrawBannedRow(BannedGroupUserDto bannedUser, int rowIndex, float colIdentity, float colMeta, float colActions) + { + using var id = ImRaii.PushId("banRow_" + bannedUser.UID); + + var style = ImGui.GetStyle(); + float x0 = ImGui.GetCursorPosX(); + + if (rowIndex % 2 == 0) + { + var drawList = ImGui.GetWindowDrawList(); + var pMin = ImGui.GetCursorScreenPos(); + var rowHeight = ImGui.GetTextLineHeightWithSpacing() * 2.6f; + var pMax = new Vector2( + pMin.X + colIdentity + colMeta + colActions + style.ItemSpacing.X * 2.0f, + pMin.Y + rowHeight); + + var bgColor = UIColors.Get("FullBlack").WithAlpha(0.10f); + drawList.AddRectFilled(pMin, pMax, ImGui.ColorConvertFloat4ToU32(bgColor)); + } + + ImGui.SetCursorPosX(x0); + ImGui.AlignTextToFramePadding(); + + string alias = bannedUser.UserAlias ?? string.Empty; + string line1 = string.IsNullOrEmpty(alias) + ? bannedUser.UID + : $"{alias} ({bannedUser.UID})"; + + ImGui.TextUnformatted(line1); + + var fullReason = bannedUser.Reason ?? string.Empty; + + if (string.Equals(_editingBanUid, bannedUser.UID, StringComparison.Ordinal)) + { + _banReasonEdits.TryGetValue(bannedUser.UID, out var editReason); + editReason ??= StripAliasSuffix(fullReason); + + ImGui.SetCursorPosX(x0); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); + ImGui.SetNextItemWidth(colIdentity); + ImGui.InputTextWithHint("##banReasonEdit", "Reason...", ref editReason, 255); + ImGui.PopStyleColor(); + + _banReasonEdits[bannedUser.UID] = editReason; + + if (!string.IsNullOrWhiteSpace(_banEditError)) + UiSharedService.ColorTextWrapped(_banEditError!, ImGuiColors.DalamudRed); + } + else + { + if (!string.IsNullOrWhiteSpace(fullReason)) + { + ImGui.SetCursorPosX(x0); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); + + ImGui.PushTextWrapPos(x0 + colIdentity); + UiSharedService.TextWrapped(fullReason); + ImGui.PopTextWrapPos(); + + ImGui.PopStyleColor(); + } + } + + ImGui.SameLine(); + ImGui.SetCursorPosX(x0 + colIdentity + style.ItemSpacing.X); + + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted($"By: {bannedUser.BannedBy}"); + + var dateText = bannedUser.BannedOn.ToLocalTime().ToString(CultureInfo.CurrentCulture); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); + ImGui.TextUnformatted(dateText); + ImGui.PopStyleColor(); + ImGui.SameLine(); + + float frame = ImGui.GetFrameHeight(); + float actionsX0 = x0 + colIdentity + colMeta + style.ItemSpacing.X * 2.0f; + + ImGui.SameLine(); + ImGui.SetCursorPosX(actionsX0); + + bool isEditing = string.Equals(_editingBanUid, bannedUser.UID, StringComparison.Ordinal); + int actionCount = 1 + (isEditing ? 2 : 1); + + float totalW = actionCount * frame + (actionCount - 1) * style.ItemSpacing.X; + float startX = actionsX0 + MathF.Max(0, colActions - totalW) - 36f; + ImGui.SetCursorPosX(startX); + + if (_uiSharedService.IconButton(FontAwesomeIcon.Check)) + { + _apiController.GroupUnbanUser(bannedUser); + _bannedUsers.RemoveAll(b => string.Equals(b.UID, bannedUser.UID, StringComparison.Ordinal)); + } + UiSharedService.AttachToolTip("Unban"); + + ImGui.SameLine(0f, style.ItemSpacing.X); + + if (!isEditing) + { + if (_uiSharedService.IconButton(FontAwesomeIcon.Edit)) + { + _banEditError = null; + _editingBanUid = bannedUser.UID; + _banReasonEdits[bannedUser.UID] = StripAliasSuffix(bannedUser.Reason ?? string.Empty); + } + UiSharedService.AttachToolTip("Edit reason"); + } + else + { + if (_uiSharedService.IconButton(FontAwesomeIcon.Save)) + { + _banEditError = null; + _banEditTask = SaveBanReasonViaBanUserAsync(bannedUser.UID); + } + UiSharedService.AttachToolTip("Save"); + + ImGui.SameLine(0f, style.ItemSpacing.X); + + if (_uiSharedService.IconButton(FontAwesomeIcon.Times)) + { + _banEditError = null; + _editingBanUid = null; + } + UiSharedService.AttachToolTip("Cancel"); + } + } + private void DrawInvites(GroupPermissions perm) { var inviteTab = ImRaii.TabItem("Invites"); @@ -902,7 +1233,9 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase if (buttonCount == 0) return; - float totalWidth = buttonCount * frameH + (buttonCount - 1) * style.ItemSpacing.X; + float totalWidth = _isOwner + ? buttonCount * frameH + buttonCount * style.ItemSpacing.X + 20f + : buttonCount * frameH + buttonCount * style.ItemSpacing.X; float curX = ImGui.GetCursorPosX(); float avail = ImGui.GetContentRegionAvail().X; @@ -1031,69 +1364,40 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.0f); } - private void DrawBannedRow(BannedGroupUserDto bannedUser, int rowIndex, float colIdentity, float colMeta, float colActions) + private void QueueBanListRefresh(bool force = false) { - using var id = ImRaii.PushId("banRow_" + bannedUser.UID); - - var style = ImGui.GetStyle(); - float x0 = ImGui.GetCursorPosX(); - - if (rowIndex % 2 == 0) + if (!force) { - var drawList = ImGui.GetWindowDrawList(); - var pMin = ImGui.GetCursorScreenPos(); - var rowHeight = ImGui.GetTextLineHeightWithSpacing() * 2.6f; - var pMax = new Vector2( - pMin.X + colIdentity + colMeta + colActions + style.ItemSpacing.X * 2.0f, - pMin.Y + rowHeight); - - var bgColor = UIColors.Get("FullBlack").WithAlpha(0.10f); - drawList.AddRectFilled(pMin, pMax, ImGui.ColorConvertFloat4ToU32(bgColor)); + if (_bannedUsersTask != null && !_bannedUsersTask.IsCompleted) + return; } - ImGui.SetCursorPosX(x0); - ImGui.AlignTextToFramePadding(); + _bannedUsersLoaded = false; + _bannedUsersLoadError = null; - string alias = bannedUser.UserAlias ?? string.Empty; - string line1 = string.IsNullOrEmpty(alias) - ? bannedUser.UID - : $"{alias} ({bannedUser.UID})"; + _bannedUsersTask = _apiController.GroupGetBannedUsers(new GroupDto(GroupFullInfo.Group)); + } - ImGui.TextUnformatted(line1); + private void EnsureBanListLoaded() + { + _bannedUsersTask ??= _apiController.GroupGetBannedUsers(new GroupDto(GroupFullInfo.Group)); - var reason = bannedUser.Reason ?? string.Empty; - if (!string.IsNullOrWhiteSpace(reason)) + if (_bannedUsersLoaded || _bannedUsersTask == null) + return; + + if (!_bannedUsersTask.IsCompleted) + return; + + if (_bannedUsersTask.IsFaulted || _bannedUsersTask.IsCanceled) { - var reasonPos = new Vector2(x0, ImGui.GetCursorPosY()); - ImGui.SetCursorPos(reasonPos); - ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); - UiSharedService.TextWrapped(reason); - ImGui.PopStyleColor(); + _bannedUsersLoadError = "Failed to load banlist from server."; + _bannedUsers = []; + _bannedUsersLoaded = true; + return; } - ImGui.SameLine(); - ImGui.SetCursorPosX(x0 + colIdentity + style.ItemSpacing.X); - - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted($"By: {bannedUser.BannedBy}"); - - var dateText = bannedUser.BannedOn.ToLocalTime().ToString(CultureInfo.CurrentCulture); - ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); - ImGui.TextUnformatted(dateText); - ImGui.PopStyleColor(); - - ImGui.SameLine(); - ImGui.SetCursorPosX(x0 + colIdentity + colMeta + style.ItemSpacing.X * 2.0f); - - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Check, "Unban")) - { - _apiController.GroupUnbanUser(bannedUser); - _bannedUsers.RemoveAll(b => string.Equals(b.UID, bannedUser.UID, StringComparison.Ordinal)); - } - - UiSharedService.AttachToolTip($"Unban {alias} ({bannedUser.UID}) from this Syncshell"); - - ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); + _bannedUsers = _bannedUsersTask.GetAwaiter().GetResult() ?? []; + _bannedUsersLoaded = true; } private void SavePruneSettings() @@ -1116,6 +1420,13 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase } } + private static string StripAliasSuffix(string reason) + { + const string marker = " (Alias at time of ban:"; + var idx = reason.IndexOf(marker, StringComparison.Ordinal); + return idx >= 0 ? reason[..idx] : reason; + } + private static bool MatchesUserFilter(Pair pair, string filterLower) { var note = pair.GetNote() ?? string.Empty; @@ -1127,6 +1438,11 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase || alias.Contains(filterLower, StringComparison.OrdinalIgnoreCase); } + public override void OnOpen() + { + base.OnOpen(); + QueueBanListRefresh(force: true); + } public override void OnClose() { Mediator.Publish(new RemoveWindowMessage(this)); -- 2.49.1 From 2eb0c463e3aa0ded28b3c97a90c8959a0dcc0688 Mon Sep 17 00:00:00 2001 From: cake Date: Sun, 4 Jan 2026 05:40:34 +0100 Subject: [PATCH 36/87] Fixed refreshing of ban list --- LightlessSync/UI/SyncshellAdminUI.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/LightlessSync/UI/SyncshellAdminUI.cs b/LightlessSync/UI/SyncshellAdminUI.cs index eee8ab9..db380a0 100644 --- a/LightlessSync/UI/SyncshellAdminUI.cs +++ b/LightlessSync/UI/SyncshellAdminUI.cs @@ -740,7 +740,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase ImGui.TextUnformatted("Add new ban"); ImGui.PopStyleColor(); - UiSharedService.TextWrapped("Enter a UID and optional reason. (Hold CTRL to enable the ban button.)"); + UiSharedService.TextWrapped("Enter a UID (Not Alias!) and optional reason. (Hold CTRL to enable the ban button.)"); var style = ImGui.GetStyle(); float fullW = ImGui.GetContentRegionAvail().X; @@ -799,6 +799,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase _newBanError ??= "Ban failed (see log)."; } + QueueBanListRefresh(force: true); _newBanTask = null; } } @@ -821,7 +822,6 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase string targetUid = uidOrAlias; string? typedAlias = null; - // Try to resolve alias to UID if applicable var snap = _pairUiService.GetSnapshot(); if (snap.GroupPairs.TryGetValue(GroupFullInfo, out var pairs)) { @@ -877,6 +877,8 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase _editingBanUid = null; _banEditError = null; + await Task.Delay(450).ConfigureAwait(false); + QueueBanListRefresh(force: true); } catch (Exception ex) -- 2.49.1 From de9c9955efc838702da407471b9b0be753b76343 Mon Sep 17 00:00:00 2001 From: defnotken Date: Sun, 4 Jan 2026 00:54:40 -0600 Subject: [PATCH 37/87] add more functionality for future features. --- .../Enums/ResidentialAetheryteKind.cs | 1 + .../Interop/Ipc/IpcCallerLifestream.cs | 38 ++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/LightlessSync/Interop/InteropModel/Enums/ResidentialAetheryteKind.cs b/LightlessSync/Interop/InteropModel/Enums/ResidentialAetheryteKind.cs index af7c18e..cd5e57c 100644 --- a/LightlessSync/Interop/InteropModel/Enums/ResidentialAetheryteKind.cs +++ b/LightlessSync/Interop/InteropModel/Enums/ResidentialAetheryteKind.cs @@ -2,6 +2,7 @@ public enum ResidentialAetheryteKind { + None = -1, Uldah = 9, Gridania = 2, Limsa = 8, diff --git a/LightlessSync/Interop/Ipc/IpcCallerLifestream.cs b/LightlessSync/Interop/Ipc/IpcCallerLifestream.cs index 2ced314..0243e59 100644 --- a/LightlessSync/Interop/Ipc/IpcCallerLifestream.cs +++ b/LightlessSync/Interop/Ipc/IpcCallerLifestream.cs @@ -1,5 +1,6 @@ using Dalamud.Plugin; using Dalamud.Plugin.Ipc; +using Lifestream.Enums; using LightlessSync.Interop.Ipc.Framework; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; @@ -20,6 +21,11 @@ public sealed class IpcCallerLifestream : IpcServiceBase private readonly ICallGateSubscriber _changeWorldById; private readonly ICallGateSubscriber _aetheryteTeleport; private readonly ICallGateSubscriber _aetheryteTeleportById; + private readonly ICallGateSubscriber _canChangeInstance; + private readonly ICallGateSubscriber _getCurrentInstance; + private readonly ICallGateSubscriber _getNumberOfInstances; + private readonly ICallGateSubscriber _changeInstance; + private readonly ICallGateSubscriber<(ResidentialAetheryteKind, int, int)> _getCurrentPlotInfo; public IpcCallerLifestream(IDalamudPluginInterface pi, LightlessMediator lightlessMediator, ILogger logger) : base(logger, lightlessMediator, pi, LifestreamDescriptor) @@ -33,6 +39,11 @@ public sealed class IpcCallerLifestream : IpcServiceBase _changeWorldById = pi.GetIpcSubscriber("Lifestream.ChangeWorldById"); _aetheryteTeleport = pi.GetIpcSubscriber("Lifestream.AetheryteTeleport"); _aetheryteTeleportById = pi.GetIpcSubscriber("Lifestream.AetheryteTeleportById"); + _canChangeInstance = pi.GetIpcSubscriber("Lifestream.CanChangeInstance"); + _getCurrentInstance = pi.GetIpcSubscriber("Lifestream.GetCurrentInstance"); + _getNumberOfInstances = pi.GetIpcSubscriber("Lifestream.GetNumberOfInstances"); + _changeInstance = pi.GetIpcSubscriber("Lifestream.ChangeInstance"); + _getCurrentPlotInfo = pi.GetIpcSubscriber<(ResidentialAetheryteKind, int, int)>("Lifestream.GetCurrentPlotInfo"); CheckAPI(); } @@ -84,10 +95,35 @@ public sealed class IpcCallerLifestream : IpcServiceBase return _changeWorldById.InvokeFunc(worldId); } - public bool AetheryteTeleportById(uint aetheryteId) { if (!APIAvailable) return false; return _aetheryteTeleportById.InvokeFunc(aetheryteId); } + + public bool CanChangeInstance() + { + if (!APIAvailable) return false; + return _canChangeInstance.InvokeFunc(); + } + public int GetCurrentInstance() + { + if (!APIAvailable) return -1; + return _getCurrentInstance.InvokeFunc(); + } + public int GetNumberOfInstances() + { + if (!APIAvailable) return -1; + return _getNumberOfInstances.InvokeFunc(); + } + public void ChangeInstance(int instanceNumber) + { + if (!APIAvailable) return; + _changeInstance.InvokeAction(instanceNumber); + } + public (ResidentialAetheryteKind, int, int)? GetCurrentPlotInfo() + { + if (!APIAvailable) return (ResidentialAetheryteKind.None, -1, -1); + return _getCurrentPlotInfo.InvokeFunc(); + } } \ No newline at end of file -- 2.49.1 From 30717ba200fdedbd4cb6725187ba7e6b8f8e8abc Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 5 Jan 2026 00:45:14 +0000 Subject: [PATCH 38/87] Merged Cake and Abel branched into 2.0.3 (#131) Co-authored-by: azyges Co-authored-by: cake Co-authored-by: defnotken Reviewed-on: https://git.lightless-sync.org/Lightless-Sync/LightlessClient/pulls/131 --- LightlessSync/FileCache/CacheMonitor.cs | 215 ++- LightlessSync/FileCache/FileCacheManager.cs | 158 +- .../FileCache/TransientResourceManager.cs | 90 +- .../Interop/BlockedCharacterHandler.cs | 24 +- LightlessSync/Interop/Ipc/IpcManager.cs | 13 +- .../Configurations/LightlessConfig.cs | 6 + .../Configurations/PlayerPerformanceConfig.cs | 11 + .../Configurations/XivDataStorageConfig.cs | 1 + LightlessSync/LightlessPlugin.cs | 15 +- .../Factories/AnimationValidationMode.cs | 9 + .../Factories/FileDownloadManagerFactory.cs | 5 + .../PlayerData/Factories/PlayerDataFactory.cs | 674 ++++--- .../Pairs/IPairPerformanceSubject.cs | 1 + LightlessSync/PlayerData/Pairs/Pair.cs | 1 + .../PlayerData/Pairs/PairCoordinator.Users.cs | 1 + .../PlayerData/Pairs/PairHandlerAdapter.cs | 873 ++++++++-- .../Pairs/PairHandlerAdapterFactory.cs | 27 +- .../PlayerData/Pairs/PairHandlerRegistry.cs | 2 +- LightlessSync/PlayerData/Pairs/PairLedger.cs | 3 +- .../Pairs/PairPerformanceMetricsCache.cs | 3 +- .../Pairs/VisibleUserDataDistributor.cs | 18 +- LightlessSync/Plugin.cs | 7 +- .../ActorTracking/ActorObjectService.cs | 102 +- LightlessSync/Services/CharacterAnalyzer.cs | 2 +- LightlessSync/Services/DalamudUtilService.cs | 40 +- LightlessSync/Services/Mediator/Messages.cs | 5 +- .../Services/ModelDecimation/MdlDecimator.cs | 1462 ++++++++++++++++ .../ModelDecimation/ModelDecimationService.cs | 381 ++++ .../Services/PlayerPerformanceService.cs | 44 +- .../TextureDownscaleService.cs | 25 +- LightlessSync/Services/XivDataAnalyzer.cs | 470 ++++- .../Algorithms/DecimationAlgorithm.cs | 169 ++ .../FastQuadricMeshSimplification.cs | 1549 +++++++++++++++++ .../ThirdParty/MeshDecimator/BoneWeight.cs | 249 +++ .../Collections/ResizableArray.cs | 179 ++ .../MeshDecimator/Collections/UVChannels.cs | 79 + .../ThirdParty/MeshDecimator/LICENSE.md | 21 + .../MeshDecimator/Math/MathHelper.cs | 286 +++ .../MeshDecimator/Math/SymmetricMatrix.cs | 303 ++++ .../ThirdParty/MeshDecimator/Math/Vector2.cs | 425 +++++ .../ThirdParty/MeshDecimator/Math/Vector2d.cs | 425 +++++ .../ThirdParty/MeshDecimator/Math/Vector2i.cs | 348 ++++ .../ThirdParty/MeshDecimator/Math/Vector3.cs | 494 ++++++ .../ThirdParty/MeshDecimator/Math/Vector3d.cs | 481 +++++ .../ThirdParty/MeshDecimator/Math/Vector3i.cs | 368 ++++ .../ThirdParty/MeshDecimator/Math/Vector4.cs | 467 +++++ .../ThirdParty/MeshDecimator/Math/Vector4d.cs | 467 +++++ .../ThirdParty/MeshDecimator/Math/Vector4i.cs | 388 +++++ .../ThirdParty/MeshDecimator/Mesh.cs | 955 ++++++++++ .../MeshDecimator/MeshDecimation.cs | 180 ++ LightlessSync/UI/CompactUI.cs | 5 + LightlessSync/UI/Components/DrawFolderTag.cs | 1 + LightlessSync/UI/Components/DrawUserPair.cs | 16 +- LightlessSync/UI/DataAnalysisUi.cs | 559 +++++- LightlessSync/UI/DownloadUi.cs | 82 +- LightlessSync/UI/DrawEntityFactory.cs | 1 + LightlessSync/UI/DtrEntry.cs | 13 +- LightlessSync/UI/Handlers/IdDisplayHandler.cs | 4 +- LightlessSync/UI/Models/PairUiEntry.cs | 1 + .../UI/Models/TextureFormatSortMode.cs | 8 + .../UI/Models/VisiblePairSortMode.cs | 1 + LightlessSync/UI/SettingsUi.cs | 379 +++- LightlessSync/UI/ZoneChatUi.cs | 14 +- LightlessSync/Utils/VariousExtensions.cs | 8 +- .../WebAPI/Files/FileDownloadManager.cs | 383 ++-- .../WebAPI/Files/Models/DownloadStatus.cs | 3 +- .../WebAPI/Files/Models/FileDownloadStatus.cs | 50 +- 67 files changed, 13247 insertions(+), 802 deletions(-) create mode 100644 LightlessSync/PlayerData/Factories/AnimationValidationMode.cs create mode 100644 LightlessSync/Services/ModelDecimation/MdlDecimator.cs create mode 100644 LightlessSync/Services/ModelDecimation/ModelDecimationService.cs create mode 100644 LightlessSync/ThirdParty/MeshDecimator/Algorithms/DecimationAlgorithm.cs create mode 100644 LightlessSync/ThirdParty/MeshDecimator/Algorithms/FastQuadricMeshSimplification.cs create mode 100644 LightlessSync/ThirdParty/MeshDecimator/BoneWeight.cs create mode 100644 LightlessSync/ThirdParty/MeshDecimator/Collections/ResizableArray.cs create mode 100644 LightlessSync/ThirdParty/MeshDecimator/Collections/UVChannels.cs create mode 100644 LightlessSync/ThirdParty/MeshDecimator/LICENSE.md create mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/MathHelper.cs create mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/SymmetricMatrix.cs create mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/Vector2.cs create mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/Vector2d.cs create mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/Vector2i.cs create mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/Vector3.cs create mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/Vector3d.cs create mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/Vector3i.cs create mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/Vector4.cs create mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/Vector4d.cs create mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/Vector4i.cs create mode 100644 LightlessSync/ThirdParty/MeshDecimator/Mesh.cs create mode 100644 LightlessSync/ThirdParty/MeshDecimator/MeshDecimation.cs create mode 100644 LightlessSync/UI/Models/TextureFormatSortMode.cs diff --git a/LightlessSync/FileCache/CacheMonitor.cs b/LightlessSync/FileCache/CacheMonitor.cs index fde9b6d..165a58c 100644 --- a/LightlessSync/FileCache/CacheMonitor.cs +++ b/LightlessSync/FileCache/CacheMonitor.cs @@ -103,6 +103,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase } record WatcherChange(WatcherChangeTypes ChangeType, string? OldPath = null); + private readonly record struct CacheEvictionCandidate(string FullPath, long Size, DateTime LastAccessTime); private readonly Dictionary _watcherChanges = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _lightlessChanges = new(StringComparer.OrdinalIgnoreCase); @@ -441,116 +442,40 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase Logger.LogWarning(ex, "Could not determine drive size for storage folder {folder}", _configService.Current.CacheFolder); } - var files = Directory.EnumerateFiles(_configService.Current.CacheFolder) - .Select(f => new FileInfo(f)) - .OrderBy(f => f.LastAccessTime) - .ToList(); - + var cacheFolder = _configService.Current.CacheFolder; + var candidates = new List(); long totalSize = 0; - - foreach (var f in files) - { - token.ThrowIfCancellationRequested(); - - try - { - long size = 0; - - if (!isWine) - { - try - { - size = _fileCompactor.GetFileSizeOnDisk(f); - } - catch (Exception ex) - { - Logger.LogTrace(ex, "GetFileSizeOnDisk failed for {file}, using fallback length", f.FullName); - size = f.Length; - } - } - else - { - size = f.Length; - } - - totalSize += size; - } - catch (Exception ex) - { - Logger.LogTrace(ex, "Error getting size for {file}", f.FullName); - } - } + totalSize += AddFolderCandidates(cacheFolder, candidates, token, isWine); + totalSize += AddFolderCandidates(Path.Combine(cacheFolder, "downscaled"), candidates, token, isWine); + totalSize += AddFolderCandidates(Path.Combine(cacheFolder, "decimated"), candidates, token, isWine); FileCacheSize = totalSize; - if (Directory.Exists(_configService.Current.CacheFolder + "/downscaled")) - { - var filesDownscaled = Directory.EnumerateFiles(_configService.Current.CacheFolder + "/downscaled").Select(f => new FileInfo(f)).OrderBy(f => f.LastAccessTime).ToList(); - - long totalSizeDownscaled = 0; - - foreach (var f in filesDownscaled) - { - token.ThrowIfCancellationRequested(); - - try - { - long size = 0; - - if (!isWine) - { - try - { - size = _fileCompactor.GetFileSizeOnDisk(f); - } - catch (Exception ex) - { - Logger.LogTrace(ex, "GetFileSizeOnDisk failed for {file}, using fallback length", f.FullName); - size = f.Length; - } - } - else - { - size = f.Length; - } - - totalSizeDownscaled += size; - } - catch (Exception ex) - { - Logger.LogTrace(ex, "Error getting size for {file}", f.FullName); - } - } - - FileCacheSize = (totalSize + totalSizeDownscaled); - } - else - { - FileCacheSize = totalSize; - } - var maxCacheInBytes = (long)(_configService.Current.MaxLocalCacheInGiB * 1024d * 1024d * 1024d); if (FileCacheSize < maxCacheInBytes) return; var maxCacheBuffer = maxCacheInBytes * 0.05d; - while (FileCacheSize > maxCacheInBytes - (long)maxCacheBuffer && files.Count > 0) + candidates.Sort(static (a, b) => a.LastAccessTime.CompareTo(b.LastAccessTime)); + + var evictionTarget = maxCacheInBytes - (long)maxCacheBuffer; + var index = 0; + while (FileCacheSize > evictionTarget && index < candidates.Count) { - var oldestFile = files[0]; + var oldestFile = candidates[index]; try { - long fileSize = oldestFile.Length; - File.Delete(oldestFile.FullName); - FileCacheSize -= fileSize; + EvictCacheCandidate(oldestFile, cacheFolder); + FileCacheSize -= oldestFile.Size; } catch (Exception ex) { - Logger.LogTrace(ex, "Failed to delete old file {file}", oldestFile.FullName); + Logger.LogTrace(ex, "Failed to delete old file {file}", oldestFile.FullPath); } - files.RemoveAt(0); + index++; } } @@ -559,6 +484,114 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase HaltScanLocks.Clear(); } + private long AddFolderCandidates(string directory, List candidates, CancellationToken token, bool isWine) + { + if (!Directory.Exists(directory)) + { + return 0; + } + + long totalSize = 0; + foreach (var path in Directory.EnumerateFiles(directory)) + { + token.ThrowIfCancellationRequested(); + + try + { + var file = new FileInfo(path); + var size = GetFileSizeOnDisk(file, isWine); + totalSize += size; + candidates.Add(new CacheEvictionCandidate(file.FullName, size, file.LastAccessTime)); + } + catch (Exception ex) + { + Logger.LogTrace(ex, "Error getting size for {file}", path); + } + } + + return totalSize; + } + + private long GetFileSizeOnDisk(FileInfo file, bool isWine) + { + if (isWine) + { + return file.Length; + } + + try + { + return _fileCompactor.GetFileSizeOnDisk(file); + } + catch (Exception ex) + { + Logger.LogTrace(ex, "GetFileSizeOnDisk failed for {file}, using fallback length", file.FullName); + return file.Length; + } + } + + private void EvictCacheCandidate(CacheEvictionCandidate candidate, string cacheFolder) + { + if (TryGetCacheHashAndPrefixedPath(candidate.FullPath, cacheFolder, out var hash, out var prefixedPath)) + { + _fileDbManager.RemoveHashedFile(hash, prefixedPath); + } + + try + { + if (File.Exists(candidate.FullPath)) + { + File.Delete(candidate.FullPath); + } + } + catch (Exception ex) + { + Logger.LogTrace(ex, "Failed to delete old file {file}", candidate.FullPath); + } + } + + private static bool TryGetCacheHashAndPrefixedPath(string filePath, string cacheFolder, out string hash, out string prefixedPath) + { + hash = string.Empty; + prefixedPath = string.Empty; + + if (string.IsNullOrEmpty(cacheFolder)) + { + return false; + } + + var fileName = Path.GetFileNameWithoutExtension(filePath); + if (string.IsNullOrEmpty(fileName) || !IsSha1Hash(fileName)) + { + return false; + } + + var relative = Path.GetRelativePath(cacheFolder, filePath) + .Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + var sanitizedRelative = relative.TrimStart(Path.DirectorySeparatorChar); + prefixedPath = Path.Combine(FileCacheManager.CachePrefix, sanitizedRelative); + hash = fileName; + return true; + } + + private static bool IsSha1Hash(string value) + { + if (value.Length != 40) + { + return false; + } + + foreach (var ch in value) + { + if (!Uri.IsHexDigit(ch)) + { + return false; + } + } + + return true; + } + public void ResumeScan(string source) { if (!HaltScanLocks.ContainsKey(source)) HaltScanLocks[source] = 0; diff --git a/LightlessSync/FileCache/FileCacheManager.cs b/LightlessSync/FileCache/FileCacheManager.cs index b0becf3..b98b441 100644 --- a/LightlessSync/FileCache/FileCacheManager.cs +++ b/LightlessSync/FileCache/FileCacheManager.cs @@ -27,6 +27,7 @@ public sealed class FileCacheManager : IHostedService private readonly ConcurrentDictionary> _fileCaches = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _fileCachesByPrefixedPath = new(StringComparer.OrdinalIgnoreCase); private readonly SemaphoreSlim _getCachesByPathsSemaphore = new(1, 1); + private readonly SemaphoreSlim _evictSemaphore = new(1, 1); private readonly Lock _fileWriteLock = new(); private readonly IpcManager _ipcManager; private readonly ILogger _logger; @@ -226,13 +227,23 @@ public sealed class FileCacheManager : IHostedService var compressed = LZ4Wrapper.WrapHC(raw, 0, raw.Length); var tmpPath = compressedPath + ".tmp"; - await File.WriteAllBytesAsync(tmpPath, compressed, token).ConfigureAwait(false); - File.Move(tmpPath, compressedPath, overwrite: true); + try + { + await File.WriteAllBytesAsync(tmpPath, compressed, token).ConfigureAwait(false); + File.Move(tmpPath, compressedPath, overwrite: true); + } + finally + { + try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch { /* ignore */ } + } - var compressedSize = compressed.LongLength; + var compressedSize = new FileInfo(compressedPath).Length; SetSizeInfo(hash, originalSize, compressedSize); UpdateEntitiesSizes(hash, originalSize, compressedSize); + var maxBytes = GiBToBytes(_configService.Current.MaxLocalCacheInGiB); + await EnforceCacheLimitAsync(maxBytes, token).ConfigureAwait(false); + return compressed; } finally @@ -280,6 +291,26 @@ public sealed class FileCacheManager : IHostedService return CreateFileEntity(cacheFolder, CachePrefix, fi); } + public FileCacheEntity? CreateCacheEntryWithKnownHash(string path, string hash) + { + if (string.IsNullOrWhiteSpace(hash)) + { + return CreateCacheEntry(path); + } + + FileInfo fi = new(path); + if (!fi.Exists) return null; + _logger.LogTrace("Creating cache entry for {path} using provided hash", path); + var cacheFolder = _configService.Current.CacheFolder; + if (string.IsNullOrEmpty(cacheFolder)) return null; + if (!TryBuildPrefixedPath(fi.FullName, cacheFolder, CachePrefix, out var prefixedPath, out _)) + { + return null; + } + + return CreateFileCacheEntity(fi, prefixedPath, hash); + } + public FileCacheEntity? CreateFileEntry(string path) { FileInfo fi = new(path); @@ -562,9 +593,10 @@ public sealed class FileCacheManager : IHostedService } } - public void RemoveHashedFile(string hash, string prefixedFilePath) + public void RemoveHashedFile(string hash, string prefixedFilePath, bool removeDerivedFiles = true) { var normalizedPath = NormalizePrefixedPathKey(prefixedFilePath); + var removedHash = false; if (_fileCaches.TryGetValue(hash, out var caches)) { @@ -577,11 +609,16 @@ public sealed class FileCacheManager : IHostedService if (caches.IsEmpty) { - _fileCaches.TryRemove(hash, out _); + removedHash = _fileCaches.TryRemove(hash, out _); } } _fileCachesByPrefixedPath.TryRemove(normalizedPath, out _); + + if (removeDerivedFiles && removedHash) + { + RemoveDerivedCacheFiles(hash); + } } public void UpdateHashedFile(FileCacheEntity fileCache, bool computeProperties = true) @@ -597,7 +634,8 @@ public sealed class FileCacheManager : IHostedService fileCache.Hash = Crypto.ComputeFileHash(fileCache.ResolvedFilepath, Crypto.HashAlgo.Sha1); fileCache.LastModifiedDateTicks = fi.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture); } - RemoveHashedFile(oldHash, prefixedPath); + var removeDerivedFiles = !string.Equals(oldHash, fileCache.Hash, StringComparison.OrdinalIgnoreCase); + RemoveHashedFile(oldHash, prefixedPath, removeDerivedFiles); AddHashedFile(fileCache); } @@ -747,7 +785,7 @@ public sealed class FileCacheManager : IHostedService { try { - RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath); + RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath, removeDerivedFiles: false); var extensionPath = fileCache.ResolvedFilepath.ToUpper(CultureInfo.InvariantCulture) + "." + ext; File.Move(fileCache.ResolvedFilepath, extensionPath, overwrite: true); var newHashedEntity = new FileCacheEntity(fileCache.Hash, fileCache.PrefixedFilePath + "." + ext, DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture)); @@ -764,6 +802,33 @@ public sealed class FileCacheManager : IHostedService } } + private void RemoveDerivedCacheFiles(string hash) + { + var cacheFolder = _configService.Current.CacheFolder; + if (string.IsNullOrWhiteSpace(cacheFolder)) + { + return; + } + + TryDeleteDerivedCacheFile(Path.Combine(cacheFolder, "downscaled", $"{hash}.tex")); + TryDeleteDerivedCacheFile(Path.Combine(cacheFolder, "decimated", $"{hash}.mdl")); + } + + private void TryDeleteDerivedCacheFile(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception ex) + { + _logger.LogTrace(ex, "Failed to delete derived cache file {path}", path); + } + } + private void AddHashedFile(FileCacheEntity fileCache) { var normalizedPath = NormalizePrefixedPathKey(fileCache.PrefixedFilePath); @@ -877,6 +942,83 @@ public sealed class FileCacheManager : IHostedService }, token).ConfigureAwait(false); } + private async Task EnforceCacheLimitAsync(long maxBytes, CancellationToken token) + { + if (string.IsNullOrWhiteSpace(CacheFolder) || maxBytes <= 0) return; + + await _evictSemaphore.WaitAsync(token).ConfigureAwait(false); + try + { + Directory.CreateDirectory(CacheFolder); + + foreach (var tmp in Directory.EnumerateFiles(CacheFolder, "*" + _compressedCacheExtension + ".tmp")) + { + try { File.Delete(tmp); } catch { /* ignore */ } + } + + var files = Directory.EnumerateFiles(CacheFolder, "*" + _compressedCacheExtension, SearchOption.TopDirectoryOnly) + .Select(p => new FileInfo(p)) + .Where(fi => fi.Exists) + .OrderBy(fi => fi.LastWriteTimeUtc) + .ToList(); + + long total = files.Sum(f => f.Length); + if (total <= maxBytes) return; + + foreach (var fi in files) + { + token.ThrowIfCancellationRequested(); + if (total <= maxBytes) break; + + var hash = Path.GetFileNameWithoutExtension(fi.Name); + + try + { + var len = fi.Length; + fi.Delete(); + total -= len; + _sizeCache.TryRemove(hash, out _); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to evict cache file {file}", fi.FullName); + } + } + } + finally + { + _evictSemaphore.Release(); + } + } + + private static long GiBToBytes(double gib) + { + if (double.IsNaN(gib) || double.IsInfinity(gib) || gib <= 0) + return 0; + + var bytes = gib * 1024d * 1024d * 1024d; + + if (bytes >= long.MaxValue) return long.MaxValue; + + return (long)Math.Round(bytes, MidpointRounding.AwayFromZero); + } + + private void CleanupOrphanCompressedCache() + { + if (string.IsNullOrWhiteSpace(CacheFolder) || !Directory.Exists(CacheFolder)) + return; + + foreach (var path in Directory.EnumerateFiles(CacheFolder, "*" + _compressedCacheExtension)) + { + var hash = Path.GetFileNameWithoutExtension(path); + if (!_fileCaches.ContainsKey(hash)) + { + try { File.Delete(path); } + catch (Exception ex) { _logger.LogWarning(ex, "Failed deleting orphan {file}", path); } + } + } + } + public async Task StartAsync(CancellationToken cancellationToken) { _logger.LogInformation("Starting FileCacheManager"); @@ -1060,6 +1202,8 @@ public sealed class FileCacheManager : IHostedService { await WriteOutFullCsvAsync(cancellationToken).ConfigureAwait(false); } + + CleanupOrphanCompressedCache(); } _logger.LogInformation("Started FileCacheManager"); diff --git a/LightlessSync/FileCache/TransientResourceManager.cs b/LightlessSync/FileCache/TransientResourceManager.cs index a8b467e..11073dc 100644 --- a/LightlessSync/FileCache/TransientResourceManager.cs +++ b/LightlessSync/FileCache/TransientResourceManager.cs @@ -297,7 +297,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase private void DalamudUtil_FrameworkUpdate() { - RefreshPlayerRelatedAddressMap(); + _ = Task.Run(() => RefreshPlayerRelatedAddressMap()); lock (_cacheAdditionLock) { @@ -306,20 +306,64 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase if (_lastClassJobId != _dalamudUtil.ClassJobId) { - _lastClassJobId = _dalamudUtil.ClassJobId; - if (SemiTransientResources.TryGetValue(ObjectKind.Pet, out HashSet? value)) - { - value?.Clear(); - } - - PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData); - SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase); - PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData); - SemiTransientResources[ObjectKind.Pet] = new HashSet( - petSpecificData ?? [], - StringComparer.OrdinalIgnoreCase); + UpdateClassJobCache(); } + CleanupAbsentObjects(); + } + + private void RefreshPlayerRelatedAddressMap() + { + var tempMap = new ConcurrentDictionary(); + var updatedFrameAddresses = new ConcurrentDictionary(); + + lock (_playerRelatedLock) + { + foreach (var handler in _playerRelatedPointers) + { + var address = (nint)handler.Address; + if (address != nint.Zero) + { + tempMap[address] = handler; + updatedFrameAddresses[address] = handler.ObjectKind; + } + } + } + + _playerRelatedByAddress.Clear(); + foreach (var kvp in tempMap) + { + _playerRelatedByAddress[kvp.Key] = kvp.Value; + } + + _cachedFrameAddresses.Clear(); + foreach (var kvp in updatedFrameAddresses) + { + _cachedFrameAddresses[kvp.Key] = kvp.Value; + } + } + + private void UpdateClassJobCache() + { + _lastClassJobId = _dalamudUtil.ClassJobId; + if (SemiTransientResources.TryGetValue(ObjectKind.Pet, out HashSet? value)) + { + value?.Clear(); + } + + PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData); + SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache + .Concat(jobSpecificData ?? []) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData); + SemiTransientResources[ObjectKind.Pet] = new HashSet( + petSpecificData ?? [], + StringComparer.OrdinalIgnoreCase); + } + + private void CleanupAbsentObjects() + { foreach (var kind in Enum.GetValues(typeof(ObjectKind)).Cast()) { if (!_cachedFrameAddresses.Any(k => k.Value == kind) && TransientResources.Remove(kind, out _)) @@ -349,26 +393,6 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase _semiTransientResources = null; } - private void RefreshPlayerRelatedAddressMap() - { - _playerRelatedByAddress.Clear(); - var updatedFrameAddresses = new ConcurrentDictionary(); - lock (_playerRelatedLock) - { - foreach (var handler in _playerRelatedPointers) - { - var address = (nint)handler.Address; - if (address != nint.Zero) - { - _playerRelatedByAddress[address] = handler; - updatedFrameAddresses[address] = handler.ObjectKind; - } - } - } - - _cachedFrameAddresses = updatedFrameAddresses; - } - private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor) { if (descriptor.IsInGpose) diff --git a/LightlessSync/Interop/BlockedCharacterHandler.cs b/LightlessSync/Interop/BlockedCharacterHandler.cs index 0ad3c80..675bf3b 100644 --- a/LightlessSync/Interop/BlockedCharacterHandler.cs +++ b/LightlessSync/Interop/BlockedCharacterHandler.cs @@ -1,4 +1,5 @@ using Dalamud.Plugin.Services; +using Dalamud.Game.ClientState.Objects.SubKinds; using FFXIVClientStructs.FFXIV.Client.Game.Character; using FFXIVClientStructs.FFXIV.Client.UI.Info; using Microsoft.Extensions.Logging; @@ -11,24 +12,35 @@ public unsafe class BlockedCharacterHandler private readonly Dictionary _blockedCharacterCache = new(); private readonly ILogger _logger; + private readonly IObjectTable _objectTable; - public BlockedCharacterHandler(ILogger logger, IGameInteropProvider gameInteropProvider) + public BlockedCharacterHandler(ILogger logger, IGameInteropProvider gameInteropProvider, IObjectTable objectTable) { gameInteropProvider.InitializeFromAttributes(this); _logger = logger; + _objectTable = objectTable; } - private static CharaData GetIdsFromPlayerPointer(nint ptr) + private CharaData? TryGetIdsFromPlayerPointer(nint ptr, ushort objectIndex) { - if (ptr == nint.Zero) return new(0, 0); - var castChar = ((BattleChara*)ptr); + if (ptr == nint.Zero || objectIndex >= 200) + return null; + + var obj = _objectTable[objectIndex]; + if (obj is not IPlayerCharacter player || player.Address != ptr) + return null; + + var castChar = (BattleChara*)player.Address; return new(castChar->Character.AccountId, castChar->Character.ContentId); } - public bool IsCharacterBlocked(nint ptr, out bool firstTime) + public bool IsCharacterBlocked(nint ptr, ushort objectIndex, out bool firstTime) { firstTime = false; - var combined = GetIdsFromPlayerPointer(ptr); + var combined = TryGetIdsFromPlayerPointer(ptr, objectIndex); + if (combined == null) + return false; + if (_blockedCharacterCache.TryGetValue(combined, out var isBlocked)) return isBlocked; diff --git a/LightlessSync/Interop/Ipc/IpcManager.cs b/LightlessSync/Interop/Ipc/IpcManager.cs index f77b084..5e95413 100644 --- a/LightlessSync/Interop/Ipc/IpcManager.cs +++ b/LightlessSync/Interop/Ipc/IpcManager.cs @@ -5,6 +5,8 @@ namespace LightlessSync.Interop.Ipc; public sealed partial class IpcManager : DisposableMediatorSubscriberBase { + private bool _wasInitialized; + public IpcManager(ILogger logger, LightlessMediator mediator, IpcCallerPenumbra penumbraIpc, IpcCallerGlamourer glamourerIpc, IpcCallerCustomize customizeIpc, IpcCallerHeels heelsIpc, IpcCallerHonorific honorificIpc, IpcCallerMoodles moodlesIpc, IpcCallerPetNames ipcCallerPetNames, IpcCallerBrio ipcCallerBrio, @@ -20,7 +22,8 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase Brio = ipcCallerBrio; Lifestream = ipcCallerLifestream; - if (Initialized) + _wasInitialized = Initialized; + if (_wasInitialized) { Mediator.Publish(new PenumbraInitializedMessage()); } @@ -60,6 +63,14 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase Moodles.CheckAPI(); PetNames.CheckAPI(); Brio.CheckAPI(); + + var initialized = Initialized; + if (initialized && !_wasInitialized) + { + Mediator.Publish(new PenumbraInitializedMessage()); + } + + _wasInitialized = initialized; Lifestream.CheckAPI(); } } \ No newline at end of file diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index 27c2856..8f1a3de 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -4,6 +4,7 @@ using LightlessSync.LightlessConfiguration.Models; using LightlessSync.UI; using LightlessSync.UI.Models; using Microsoft.Extensions.Logging; +using LightlessSync.PlayerData.Factories; namespace LightlessSync.LightlessConfiguration.Configurations; @@ -51,6 +52,7 @@ public class LightlessConfig : ILightlessConfiguration public bool PreferNotesOverNamesForVisible { get; set; } = false; public VisiblePairSortMode VisiblePairSortMode { get; set; } = VisiblePairSortMode.Alphabetical; public OnlinePairSortMode OnlinePairSortMode { get; set; } = OnlinePairSortMode.Alphabetical; + public TextureFormatSortMode TextureFormatSortMode { get; set; } = TextureFormatSortMode.None; public float ProfileDelay { get; set; } = 1.5f; public bool ProfilePopoutRight { get; set; } = false; public bool ProfilesAllowNsfw { get; set; } = false; @@ -157,4 +159,8 @@ public class LightlessConfig : ILightlessConfiguration public string LastSeenVersion { get; set; } = string.Empty; public bool EnableParticleEffects { get; set; } = true; public HashSet OrphanableTempCollections { get; set; } = []; + public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Safe; + public bool AnimationAllowOneBasedShift { get; set; } = true; + + public bool AnimationAllowNeighborIndexTolerance { get; set; } = false; } diff --git a/LightlessSync/LightlessConfiguration/Configurations/PlayerPerformanceConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/PlayerPerformanceConfig.cs index 7da9ac2..462a63f 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/PlayerPerformanceConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/PlayerPerformanceConfig.cs @@ -22,4 +22,15 @@ public class PlayerPerformanceConfig : ILightlessConfiguration public int TextureDownscaleMaxDimension { get; set; } = 2048; public bool OnlyDownscaleUncompressedTextures { get; set; } = true; public bool KeepOriginalTextureFiles { get; set; } = false; + public bool SkipTextureDownscaleForPreferredPairs { get; set; } = true; + public bool EnableModelDecimation { get; set; } = false; + public int ModelDecimationTriangleThreshold { get; set; } = 20_000; + public double ModelDecimationTargetRatio { get; set; } = 0.8; + public bool KeepOriginalModelFiles { get; set; } = true; + public bool SkipModelDecimationForPreferredPairs { get; set; } = true; + public bool ModelDecimationAllowBody { get; set; } = false; + public bool ModelDecimationAllowFaceHead { get; set; } = false; + public bool ModelDecimationAllowTail { get; set; } = false; + public bool ModelDecimationAllowClothing { get; set; } = true; + public bool ModelDecimationAllowAccessories { get; set; } = true; } \ No newline at end of file diff --git a/LightlessSync/LightlessConfiguration/Configurations/XivDataStorageConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/XivDataStorageConfig.cs index 8444ae8..ce7990a 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/XivDataStorageConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/XivDataStorageConfig.cs @@ -5,6 +5,7 @@ namespace LightlessSync.LightlessConfiguration.Configurations; public class XivDataStorageConfig : ILightlessConfiguration { public ConcurrentDictionary TriangleDictionary { get; set; } = new(StringComparer.OrdinalIgnoreCase); + public ConcurrentDictionary EffectiveTriangleDictionary { 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/LightlessSync/LightlessPlugin.cs b/LightlessSync/LightlessPlugin.cs index fe7e9a4..e82235f 100644 --- a/LightlessSync/LightlessPlugin.cs +++ b/LightlessSync/LightlessPlugin.cs @@ -74,6 +74,7 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService private readonly DalamudUtilService _dalamudUtil; private readonly LightlessConfigService _lightlessConfigService; private readonly ServerConfigurationManager _serverConfigurationManager; + private readonly PairHandlerRegistry _pairHandlerRegistry; private readonly IServiceScopeFactory _serviceScopeFactory; private IServiceScope? _runtimeServiceScope; private Task? _launchTask = null; @@ -81,11 +82,13 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService public LightlessPlugin(ILogger logger, LightlessConfigService lightlessConfigService, ServerConfigurationManager serverConfigurationManager, DalamudUtilService dalamudUtil, + PairHandlerRegistry pairHandlerRegistry, IServiceScopeFactory serviceScopeFactory, LightlessMediator mediator) : base(logger, mediator) { _lightlessConfigService = lightlessConfigService; _serverConfigurationManager = serverConfigurationManager; _dalamudUtil = dalamudUtil; + _pairHandlerRegistry = pairHandlerRegistry; _serviceScopeFactory = serviceScopeFactory; } @@ -108,12 +111,20 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService public Task StopAsync(CancellationToken cancellationToken) { + Logger.LogDebug("Halting LightlessPlugin"); + try + { + _pairHandlerRegistry.ResetAllHandlers(); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to reset pair handlers on shutdown"); + } + UnsubscribeAll(); DalamudUtilOnLogOut(); - Logger.LogDebug("Halting LightlessPlugin"); - return Task.CompletedTask; } diff --git a/LightlessSync/PlayerData/Factories/AnimationValidationMode.cs b/LightlessSync/PlayerData/Factories/AnimationValidationMode.cs new file mode 100644 index 0000000..ca73117 --- /dev/null +++ b/LightlessSync/PlayerData/Factories/AnimationValidationMode.cs @@ -0,0 +1,9 @@ +namespace LightlessSync.PlayerData.Factories +{ + public enum AnimationValidationMode + { + Unsafe = 0, + Safe = 1, + Safest = 2, + } +} diff --git a/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs b/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs index e3697cf..211a6fc 100644 --- a/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs +++ b/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs @@ -1,6 +1,7 @@ using LightlessSync.FileCache; using LightlessSync.LightlessConfiguration; using LightlessSync.Services.Mediator; +using LightlessSync.Services.ModelDecimation; using LightlessSync.Services.TextureCompression; using LightlessSync.WebAPI.Files; using Microsoft.Extensions.Logging; @@ -16,6 +17,7 @@ public class FileDownloadManagerFactory private readonly FileCompactor _fileCompactor; private readonly LightlessConfigService _configService; private readonly TextureDownscaleService _textureDownscaleService; + private readonly ModelDecimationService _modelDecimationService; private readonly TextureMetadataHelper _textureMetadataHelper; public FileDownloadManagerFactory( @@ -26,6 +28,7 @@ public class FileDownloadManagerFactory FileCompactor fileCompactor, LightlessConfigService configService, TextureDownscaleService textureDownscaleService, + ModelDecimationService modelDecimationService, TextureMetadataHelper textureMetadataHelper) { _loggerFactory = loggerFactory; @@ -35,6 +38,7 @@ public class FileDownloadManagerFactory _fileCompactor = fileCompactor; _configService = configService; _textureDownscaleService = textureDownscaleService; + _modelDecimationService = modelDecimationService; _textureMetadataHelper = textureMetadataHelper; } @@ -48,6 +52,7 @@ public class FileDownloadManagerFactory _fileCompactor, _configService, _textureDownscaleService, + _modelDecimationService, _textureMetadataHelper); } } diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index 9ecfcc3..5c5b580 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -2,12 +2,15 @@ using LightlessSync.API.Data.Enum; 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 Microsoft.Extensions.Logging; +using System.Collections.Concurrent; +using System.Diagnostics; namespace LightlessSync.PlayerData.Factories; @@ -18,13 +21,34 @@ public class PlayerDataFactory 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); - public PlayerDataFactory(ILogger logger, DalamudUtilService dalamudUtil, IpcManager ipcManager, - TransientResourceManager transientResourceManager, FileCacheManager fileReplacementFactory, - PerformanceCollectorService performanceCollector, XivDataAnalyzer modelAnalyzer, LightlessMediator lightlessMediator) + // 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; @@ -34,15 +58,15 @@ public class PlayerDataFactory _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; @@ -67,16 +91,17 @@ public class PlayerDataFactory if (pointerIsZero) { - _logger.LogTrace("Pointer was zero for {objectKind}", playerRelatedObject.ObjectKind); + _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 () => - { - return await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false); - }).ConfigureAwait(true); + return await _performanceCollector.LogPerformance( + this, + $"CreateCharacterData>{playerRelatedObject.ObjectKind}", + async () => await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false) + ).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -92,17 +117,14 @@ public class PlayerDataFactory } private async Task CheckForNullDrawObject(IntPtr playerPointer) - { - return await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false); - } + => await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false); - private unsafe bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer) + private unsafe static bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer) { if (playerPointer == IntPtr.Zero) return true; var character = (Character*)playerPointer; - if (character == null) return true; @@ -113,93 +135,177 @@ public class PlayerDataFactory return gameObject->DrawObject == null; } - private async Task CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct) + 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 objectKind = playerRelatedObject.ObjectKind; - CharacterDataFragment fragment = objectKind == ObjectKind.Player ? new CharacterDataFragmentPlayer() : new(); + var key = obj.Address; - _logger.LogDebug("Building character data for {obj}", playerRelatedObject); - var logDebug = _logger.IsEnabled(LogLevel.Debug); + if (_characterBuildCache.TryGetValue(key, out var cached) && IsCacheFresh(cached) && !_characterBuildInflight.ContainsKey(key)) + return cached.Fragment; - // wait until chara is not drawing and present so nothing spontaneously explodes - await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct).ConfigureAwait(false); - int totalWaitTime = 10000; - while (!await _dalamudUtil.IsObjectPresentAsync(await _dalamudUtil.CreateGameObjectAsync(playerRelatedObject.Address).ConfigureAwait(false)).ConfigureAwait(false) && totalWaitTime > 0) + var buildTask = _characterBuildInflight.GetOrAdd(key, _ => BuildAndCacheAsync(obj, key)); + + if (_characterBuildCache.TryGetValue(key, out cached)) { - _logger.LogTrace("Character is null but it shouldn't be, waiting"); - await Task.Delay(50, ct).ConfigureAwait(false); - totalWaitTime -= 50; + 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; + } } - ct.ThrowIfCancellationRequested(); + return await WithCancellation(buildTask, ct).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"); - - ct.ThrowIfCancellationRequested(); + private async Task BuildAndCacheAsync(GameObjectHandler obj, nint key) + { + try + { + using var cts = new CancellationTokenSource(_hardBuildTimeout); + var fragment = await CreateCharacterDataInternal(obj, cts.Token).ConfigureAwait(false); fragment.FileReplacements = new HashSet(resolvedPaths.Select(c => new FileReplacement([.. c.Value], c.Key)), FileReplacementComparer.Instance) .Where(p => p.HasFileReplacement).ToHashSet(); - fragment.FileReplacements.RemoveWhere(c => c.GamePaths.Any(g => !CacheMonitor.AllowedFileExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase)))); + var allowedExtensions = CacheMonitor.AllowedFileExtensions; + fragment.FileReplacements.RemoveWhere(c => c.GamePaths.Any(g => !allowedExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase)))); + _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 + var transientPaths = ManageSemiTransientData(objectKind); + var resolvedTransientPaths = transientPaths.Count == 0 + ? new Dictionary(StringComparer.OrdinalIgnoreCase).AsReadOnly() + : await GetFileReplacementsFromPaths(playerRelatedObject, transientPaths, new HashSet(StringComparer.Ordinal)).ConfigureAwait(false); + 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)) + foreach (var replacement in fragment.FileReplacements + .Where(i => i.HasFileReplacement) + .OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase)) { _logger.LogDebug("=> {repl}", replacement); ct.ThrowIfCancellationRequested(); } } - else + + 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) { - foreach (var replacement in fragment.FileReplacements.Where(i => i.HasFileReplacement)) - { - ct.ThrowIfCancellationRequested(); - } - } + CharacterDataFragmentPlayer? playerFragment = fragment as CharacterDataFragmentPlayer ?? throw new InvalidOperationException("Failed to cast CharacterDataFragment to Player variant"); - await _transientResourceManager.WaitForRecording(ct).ConfigureAwait(false); + playerFragment.ManipulationString = _ipcManager.Penumbra.GetMetaManipulations(); + playerFragment.HonorificData = await getHonorificTitle!.ConfigureAwait(false); + _logger.LogDebug("Honorific is now: {data}", playerFragment!.HonorificData); - // 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 fragment.FileReplacements.Where(i => i.HasFileReplacement).SelectMany(p => p.GamePaths)) - { - if (_transientResourceManager.AddTransientResource(objectKind, item)) - { - _logger.LogDebug("Marking static {item} for Pet as transient", item); - } - } + playerFragment.PetNamesData = _ipcManager.PetNames.GetLocalNames(); + _logger.LogDebug("Pet Nicknames is now: {petnames}", playerFragment!.PetNamesData); - _logger.LogTrace("Clearing {count} Static Replacements for Pet", fragment.FileReplacements.Count); - fragment.FileReplacements.Clear(); + 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(); - _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(objectKind, fragment.FileReplacements.SelectMany(c => c.GamePaths).ToList()); - - // get all remaining paths and resolve them - var transientPaths = ManageSemiTransientData(objectKind); - var resolvedTransientPaths = await GetFileReplacementsFromPaths(playerRelatedObject, transientPaths, new HashSet(StringComparer.Ordinal)).ConfigureAwait(false); + 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)) + 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); @@ -208,85 +314,64 @@ public class PlayerDataFactory else { foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key))) - { fragment.FileReplacements.Add(replacement); - } } - // clean up all semi transient resources that don't have any file replacement (aka null resolve) _transientResourceManager.CleanUpSemiTransientResources(objectKind, [.. fragment.FileReplacements]); - ct.ThrowIfCancellationRequested(); - - // make sure we only return data that actually has file replacements - fragment.FileReplacements = new HashSet(fragment.FileReplacements.Where(v => v.HasFileReplacement).OrderBy(v => v.ResolvedPath, StringComparer.Ordinal), FileReplacementComparer.Instance); - - // gather up data from ipc - Task getHeelsOffset = _ipcManager.Heels.GetOffsetAsync(); - Task getGlamourerData = _ipcManager.Glamourer.GetCharacterCustomizationAsync(playerRelatedObject.Address); - Task getCustomizeData = _ipcManager.CustomizePlus.GetScaleAsync(playerRelatedObject.Address); - Task getHonorificTitle = _ipcManager.Honorific.GetTitle(); - 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) - { - var playerFragment = (fragment as CharacterDataFragmentPlayer)!; - playerFragment.ManipulationString = _ipcManager.Penumbra.GetMetaManipulations(); - - playerFragment!.HonorificData = await getHonorificTitle.ConfigureAwait(false); - _logger.LogDebug("Honorific is now: {data}", playerFragment!.HonorificData); - - playerFragment!.HeelsData = await getHeelsOffset.ConfigureAwait(false); - _logger.LogDebug("Heels is now: {heels}", playerFragment!.HeelsData); - - playerFragment!.MoodlesData = await _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address).ConfigureAwait(false) ?? string.Empty; - _logger.LogDebug("Moodles is now: {moodles}", playerFragment!.MoodlesData); - - playerFragment!.PetNamesData = _ipcManager.PetNames.GetLocalNames(); - _logger.LogDebug("Pet Nicknames is now: {petnames}", playerFragment!.PetNamesData); - } + 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); - var computedPaths = _fileCacheManager.GetFileCachesByPaths(toCompute.Select(c => c.ResolvedPath).ToArray()); - foreach (var file in toCompute) + + await Task.Run(() => { - ct.ThrowIfCancellationRequested(); - file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty; - } + 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.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase)); - if (hasPapFiles) - { - boneIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject)).ConfigureAwait(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, (fragment as CharacterDataFragmentPlayer)!, ct).ConfigureAwait(false); + await VerifyPlayerAnimationBones(boneIndices, (CharacterDataFragmentPlayer)fragment, ct) + .ConfigureAwait(false); } } catch (OperationCanceledException e) @@ -300,105 +385,277 @@ public class PlayerDataFactory } } - _logger.LogInformation("Building character data for {obj} took {time}ms", objectKind, TimeSpan.FromTicks(DateTime.UtcNow.Ticks - start.Ticks).TotalMilliseconds); + _logger.LogInformation("Building character data for {obj} took {time}ms", + objectKind, sw.Elapsed.TotalMilliseconds); return fragment; } - private async Task VerifyPlayerAnimationBones(Dictionary>? boneIndices, CharacterDataFragmentPlayer fragment, CancellationToken ct) + private async Task EnsureObjectPresentAsync(GameObjectHandler handler, CancellationToken ct) { - if (boneIndices == null) return; - - if (_logger.IsEnabled(LogLevel.Debug)) - { - 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)); - } - } - - var maxPlayerBoneIndex = boneIndices.SelectMany(kvp => kvp.Value).DefaultIfEmpty().Max(); - if (maxPlayerBoneIndex <= 0) return; - - int noValidationFailed = 0; - foreach (var file in fragment.FileReplacements.Where(f => !f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase)).ToList()) + var remaining = 10000; + while (remaining > 0) { ct.ThrowIfCancellationRequested(); - var skeletonIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetBoneIndicesFromPap(file.Hash)).ConfigureAwait(false); - bool validationFailed = false; - if (skeletonIndices != null) + 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)) { - // 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; - } + if (_transientResourceManager.AddTransientResource(objectKind, item)) + _logger.LogDebug("Marking static {item} for Pet as transient", item); + } - _logger.LogDebug("Verifying bone indices for {path}, found {x} skeletons", file.ResolvedPath, skeletonIndices.Count); + _logger.LogTrace("Clearing {count} Static Replacements for Pet", staticReplacements.Count); + clearedReplacements = staticReplacements; + } - foreach (var boneCount in skeletonIndices) - { - var maxAnimationIndex = boneCount.Value.DefaultIfEmpty().Max(); - if (maxAnimationIndex > maxPlayerBoneIndex) + 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; + + Dictionary>? papIndices = null; + + await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false); + try + { + papIndices = await Task.Run(() => _modelAnalyzer.GetBoneIndicesFromPap(hash), ct) + .ConfigureAwait(false); + } + finally + { + _papParseLimiter.Release(); + } + + if (papIndices == null || papIndices.Count == 0) + continue; + + if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105)) + continue; + + if (_logger.IsEnabled(LogLevel.Debug)) + { + var papBuckets = papIndices + .Select(kvp => new { - _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, maxAnimationIndex, maxPlayerBoneIndex); - validationFailed = true; - break; - } - } + 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)); } - if (validationFailed) - { - noValidationFailed++; - _logger.LogDebug("Removing {file} from sent file replacements and transient data", file.ResolvedPath); - fragment.FileReplacements.Remove(file); - foreach (var gamePath in file.GamePaths) - { - _transientResourceManager.RemoveTransientResource(ObjectKind.Player, gamePath); - } - } + if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out var reason)) + continue; + noValidationFailed++; + + _logger.LogWarning( + "Animation PAP hash {hash} is not compatible with local skeletons; dropping all mappings for this hash. Reason: {reason}", + hash, + 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 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))); + _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) + + private async Task> GetFileReplacementsFromPaths( + GameObjectHandler handler, + HashSet forwardResolve, + HashSet reverseResolve) { var forwardPaths = forwardResolve.ToArray(); var reversePaths = reverseResolve.ToArray(); - Dictionary> resolvedPaths = new(StringComparer.Ordinal); + if (forwardPaths.Length == 0 && reversePaths.Length == 0) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase).AsReadOnly(); + } + + var forwardPathsLower = forwardPaths.Length == 0 ? Array.Empty() : forwardPaths.Select(p => p.ToLowerInvariant()).ToArray(); + var reversePathsLower = reversePaths.Length == 0 ? Array.Empty() : 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); @@ -409,14 +666,10 @@ public class PlayerDataFactory { 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] = [forwardPaths[i].ToLowerInvariant()]; @@ -425,15 +678,16 @@ public class PlayerDataFactory for (int i = 0; i < reversePaths.Length; i++) { - var filePath = reversePaths[i].ToLowerInvariant(); + 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] = new List(reverseResolved[i].Select(c => c.ToLowerInvariant()).ToList()); - } + resolvedPaths[filePath] = [.. reverseResolved[i].Select(c => c.ToLowerInvariant()).ToList()]; } return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly(); @@ -441,30 +695,28 @@ public class PlayerDataFactory } 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(); + 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] = new List(reverse[i].Select(c => c.ToLowerInvariant()).ToList()); - } + resolvedPaths[filePath] = [.. reverse[i].Select(c => c.ToLowerInvariant()).ToList()]; } return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly(); @@ -475,11 +727,29 @@ public class PlayerDataFactory _transientResourceManager.PersistTransientResources(objectKind); HashSet pathsToResolve = new(StringComparer.Ordinal); - foreach (var path in _transientResourceManager.GetSemiTransientResources(objectKind).Where(path => !string.IsNullOrEmpty(path))) + + 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; } -} \ No newline at end of file +} diff --git a/LightlessSync/PlayerData/Pairs/IPairPerformanceSubject.cs b/LightlessSync/PlayerData/Pairs/IPairPerformanceSubject.cs index cd62f98..b6355a8 100644 --- a/LightlessSync/PlayerData/Pairs/IPairPerformanceSubject.cs +++ b/LightlessSync/PlayerData/Pairs/IPairPerformanceSubject.cs @@ -16,4 +16,5 @@ public interface IPairPerformanceSubject long LastAppliedApproximateVRAMBytes { get; set; } long LastAppliedApproximateEffectiveVRAMBytes { get; set; } long LastAppliedDataTris { get; set; } + long LastAppliedApproximateEffectiveTris { get; set; } } diff --git a/LightlessSync/PlayerData/Pairs/Pair.cs b/LightlessSync/PlayerData/Pairs/Pair.cs index 2a85cd3..e95b7fe 100644 --- a/LightlessSync/PlayerData/Pairs/Pair.cs +++ b/LightlessSync/PlayerData/Pairs/Pair.cs @@ -69,6 +69,7 @@ public class Pair public string? PlayerName => TryGetHandler()?.PlayerName ?? UserPair.User.AliasOrUID; public long LastAppliedDataBytes => TryGetHandler()?.LastAppliedDataBytes ?? -1; public long LastAppliedDataTris => TryGetHandler()?.LastAppliedDataTris ?? -1; + public long LastAppliedApproximateEffectiveTris => TryGetHandler()?.LastAppliedApproximateEffectiveTris ?? -1; public long LastAppliedApproximateVRAMBytes => TryGetHandler()?.LastAppliedApproximateVRAMBytes ?? -1; public long LastAppliedApproximateEffectiveVRAMBytes => TryGetHandler()?.LastAppliedApproximateEffectiveVRAMBytes ?? -1; public string Ident => TryGetHandler()?.Ident ?? TryGetConnection()?.Ident ?? string.Empty; diff --git a/LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs b/LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs index 0891035..713333e 100644 --- a/LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs +++ b/LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs @@ -125,6 +125,7 @@ public sealed partial class PairCoordinator } } + _mediator.Publish(new PairOnlineMessage(new PairUniqueIdentifier(dto.User.UID))); PublishPairDataChanged(); } diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index b0f2710..82f4749 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -1,16 +1,19 @@ using System.Collections.Concurrent; using System.Diagnostics; +using Dalamud.Plugin.Services; using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; using LightlessSync.FileCache; using LightlessSync.Interop.Ipc; +using LightlessSync.LightlessConfiguration; using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Handlers; using LightlessSync.Services; using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Events; using LightlessSync.Services.Mediator; +using LightlessSync.Services.ModelDecimation; using LightlessSync.Services.PairProcessing; using LightlessSync.Services.ServerConfiguration; using LightlessSync.Services.TextureCompression; @@ -22,6 +25,7 @@ using Microsoft.Extensions.Logging; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind; using FileReplacementDataComparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer; +using LightlessSync.LightlessConfiguration; namespace LightlessSync.PlayerData.Pairs; @@ -36,6 +40,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private readonly ActorObjectService _actorObjectService; private readonly FileDownloadManager _downloadManager; private readonly FileCacheManager _fileDbManager; + private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; private readonly GameObjectHandlerFactory _gameObjectHandlerFactory; private readonly IpcManager _ipcManager; private readonly IHostApplicationLifetime _lifetime; @@ -44,10 +49,14 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private readonly ServerConfigurationManager _serverConfigManager; private readonly PluginWarningNotificationService _pluginWarningNotificationManager; private readonly TextureDownscaleService _textureDownscaleService; + private readonly ModelDecimationService _modelDecimationService; private readonly PairStateCache _pairStateCache; private readonly PairPerformanceMetricsCache _performanceMetricsCache; + private readonly XivDataAnalyzer _modelAnalyzer; private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor; + private readonly LightlessConfigService _configService; private readonly PairManager _pairManager; + private readonly IFramework _framework; private CancellationTokenSource? _applicationCancellationTokenSource; private Guid _applicationId; private Task? _applicationTask; @@ -66,6 +75,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private int _lastMissingNonCriticalMods; private int _lastMissingForbiddenMods; private bool _lastMissingCachedFiles; + private string? _lastSuccessfulDataHash; private bool _isVisible; private Guid _penumbraCollection; private readonly object _collectionGate = new(); @@ -82,6 +92,13 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private readonly object _visibilityGraceGate = new(); private CancellationTokenSource? _visibilityGraceCts; private static readonly TimeSpan VisibilityEvictionGrace = TimeSpan.FromMinutes(1); + private readonly object _ownedRetryGate = new(); + private readonly Dictionary> _pendingOwnedChanges = new(); + private CancellationTokenSource? _ownedRetryCts; + private Task _ownedRetryTask = Task.CompletedTask; + private static readonly TimeSpan OwnedRetryInitialDelay = TimeSpan.FromSeconds(1); + private static readonly TimeSpan OwnedRetryMaxDelay = TimeSpan.FromSeconds(10); + private static readonly TimeSpan OwnedRetryStaleDataGrace = TimeSpan.FromMinutes(5); private static readonly HashSet NonPriorityModExtensions = new(StringComparer.OrdinalIgnoreCase) { ".tmb", @@ -90,15 +107,24 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa ".avfx", ".scd" }; + + private readonly ConcurrentDictionary _blockedPapHashes = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _dumpedRemoteSkeletonForHash = new(StringComparer.OrdinalIgnoreCase); + private DateTime? _invisibleSinceUtc; private DateTime? _visibilityEvictionDueAtUtc; private DateTime _nextActorLookupUtc = DateTime.MinValue; private static readonly TimeSpan ActorLookupInterval = TimeSpan.FromSeconds(1); private static readonly SemaphoreSlim ActorInitializationLimiter = new(1, 1); + private const int FullyLoadedTimeoutMsPlayer = 30000; + private const int FullyLoadedTimeoutMsOther = 5000; private readonly object _actorInitializationGate = new(); private ActorObjectService.ActorDescriptor? _pendingActorDescriptor; private bool _actorInitializationInProgress; private bool _frameworkUpdateSubscribed; + private nint _lastKnownAddress = nint.Zero; + private ushort _lastKnownObjectIndex = ushort.MaxValue; + private string? _lastKnownName; public DateTime? InvisibleSinceUtc => _invisibleSinceUtc; public DateTime? VisibilityEvictionDueAtUtc => _visibilityEvictionDueAtUtc; @@ -147,6 +173,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa public long LastAppliedDataBytes { get; private set; } public long LastAppliedDataTris { get; set; } = -1; + public long LastAppliedApproximateEffectiveTris { get; set; } = -1; public long LastAppliedApproximateVRAMBytes { get; set; } = -1; public long LastAppliedApproximateEffectiveVRAMBytes { get; set; } = -1; public CharacterData? LastReceivedCharacterData { get; private set; } @@ -175,16 +202,21 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa FileDownloadManager transferManager, PluginWarningNotificationService pluginWarningNotificationManager, DalamudUtilService dalamudUtil, + IFramework framework, ActorObjectService actorObjectService, IHostApplicationLifetime lifetime, FileCacheManager fileDbManager, + PlayerPerformanceConfigService playerPerformanceConfigService, PlayerPerformanceService playerPerformanceService, PairProcessingLimiter pairProcessingLimiter, ServerConfigurationManager serverConfigManager, TextureDownscaleService textureDownscaleService, + ModelDecimationService modelDecimationService, PairStateCache pairStateCache, PairPerformanceMetricsCache performanceMetricsCache, - PenumbraTempCollectionJanitor tempCollectionJanitor) : base(logger, mediator) + PenumbraTempCollectionJanitor tempCollectionJanitor, + XivDataAnalyzer modelAnalyzer, + LightlessConfigService configService) : base(logger, mediator) { _pairManager = pairManager; Ident = ident; @@ -193,16 +225,21 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _downloadManager = transferManager; _pluginWarningNotificationManager = pluginWarningNotificationManager; _dalamudUtil = dalamudUtil; + _framework = framework; _actorObjectService = actorObjectService; _lifetime = lifetime; _fileDbManager = fileDbManager; + _playerPerformanceConfigService = playerPerformanceConfigService; _playerPerformanceService = playerPerformanceService; _pairProcessingLimiter = pairProcessingLimiter; _serverConfigManager = serverConfigManager; _textureDownscaleService = textureDownscaleService; + _modelDecimationService = modelDecimationService; _pairStateCache = pairStateCache; _performanceMetricsCache = performanceMetricsCache; _tempCollectionJanitor = tempCollectionJanitor; + _modelAnalyzer = modelAnalyzer; + _configService = configService; } public void Initialize() @@ -225,7 +262,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return; } - if (LastAppliedDataBytes < 0 || LastAppliedDataTris < 0 + if (LastAppliedDataBytes < 0 || LastAppliedDataTris < 0 || LastAppliedApproximateEffectiveTris < 0 || LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0) { _forceApplyMods = true; @@ -432,7 +469,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } } - private void ResetPenumbraCollection(bool releaseFromPenumbra = true, string? reason = null) + private void ResetPenumbraCollection(bool releaseFromPenumbra = true, string? reason = null, bool awaitIpc = true) { Guid toRelease = Guid.Empty; bool hadCollection = false; @@ -466,16 +503,33 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return; } - try + var applicationId = Guid.NewGuid(); + if (awaitIpc) { - var applicationId = Guid.NewGuid(); - Logger.LogTrace("[{applicationId}] Removing temp collection {CollectionId} for {handler} ({reason})", applicationId, toRelease, GetLogIdentifier(), reason ?? "Cleanup"); - _ipcManager.Penumbra.RemoveTemporaryCollectionAsync(Logger, applicationId, toRelease).GetAwaiter().GetResult(); + try + { + Logger.LogTrace("[{applicationId}] Removing temp collection {CollectionId} for {handler} ({reason})", applicationId, toRelease, GetLogIdentifier(), reason ?? "Cleanup"); + _ipcManager.Penumbra.RemoveTemporaryCollectionAsync(Logger, applicationId, toRelease).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed to remove temporary Penumbra collection for {handler}", GetLogIdentifier()); + } + return; } - catch (Exception ex) + + _ = Task.Run(async () => { - Logger.LogDebug(ex, "Failed to remove temporary Penumbra collection for {handler}", GetLogIdentifier()); - } + try + { + Logger.LogTrace("[{applicationId}] Removing temp collection {CollectionId} for {handler} ({reason})", applicationId, toRelease, GetLogIdentifier(), reason ?? "Cleanup"); + await _ipcManager.Penumbra.RemoveTemporaryCollectionAsync(Logger, applicationId, toRelease).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed to remove temporary Penumbra collection for {handler}", GetLogIdentifier()); + } + }); } private bool AnyPair(Func predicate) @@ -483,11 +537,31 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return GetCurrentPairs().Any(predicate); } - private bool ShouldSkipDownscale() + private bool IsPreferredDirectPair() { return GetCurrentPairs().Any(p => p.IsDirectlyPaired && p.SelfToOtherPermissions.IsSticky()); } + private bool ShouldSkipDownscale() + { + if (!_playerPerformanceConfigService.Current.SkipTextureDownscaleForPreferredPairs) + { + return false; + } + + return IsPreferredDirectPair(); + } + + private bool ShouldSkipDecimation() + { + if (!_playerPerformanceConfigService.Current.SkipModelDecimationForPreferredPairs) + { + return false; + } + + return IsPreferredDirectPair(); + } + private bool IsPaused() { var pairs = GetCurrentPairs(); @@ -545,6 +619,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _forceApplyMods = true; LastAppliedDataBytes = -1; LastAppliedDataTris = -1; + LastAppliedApproximateEffectiveTris = -1; LastAppliedApproximateVRAMBytes = -1; LastAppliedApproximateEffectiveVRAMBytes = -1; } @@ -559,9 +634,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } var hasMissingCachedFiles = HasMissingCachedFiles(LastReceivedCharacterData); + var missingStarted = !_lastMissingCachedFiles && hasMissingCachedFiles; var missingResolved = _lastMissingCachedFiles && !hasMissingCachedFiles; _lastMissingCachedFiles = hasMissingCachedFiles; - var shouldForce = forced || missingResolved; + var shouldForce = forced || missingStarted || missingResolved; + var forceApplyCustomization = forced; if (IsPaused()) { @@ -569,25 +646,46 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return; } - if (shouldForce) - { - _forceApplyMods = true; - _forceFullReapply = true; - LastAppliedDataBytes = -1; - LastAppliedDataTris = -1; - LastAppliedApproximateVRAMBytes = -1; - LastAppliedApproximateEffectiveVRAMBytes = -1; - } - - var sanitized = CloneAndSanitizeLastReceived(out _); + var sanitized = CloneAndSanitizeLastReceived(out var dataHash); if (sanitized is null) { Logger.LogTrace("Sanitized data null for {Ident}", Ident); return; } + var dataApplied = !string.IsNullOrEmpty(dataHash) + && string.Equals(dataHash, _lastSuccessfulDataHash ?? string.Empty, StringComparison.Ordinal); + var needsApply = !dataApplied; + var modFilesChanged = PlayerModFilesChanged(sanitized, _cachedData); + var shouldForceMods = shouldForce || modFilesChanged; + forceApplyCustomization = forced || needsApply; + var suppressForcedModRedraw = !forced && hasMissingCachedFiles && dataApplied; + + if (shouldForceMods) + { + _forceApplyMods = true; + _forceFullReapply = true; + LastAppliedDataBytes = -1; + LastAppliedDataTris = -1; + LastAppliedApproximateEffectiveTris = -1; + LastAppliedApproximateVRAMBytes = -1; + LastAppliedApproximateEffectiveVRAMBytes = -1; + } _pairStateCache.Store(Ident, sanitized); + if (!IsVisible && !_pauseRequested) + { + if (_charaHandler is not null && _charaHandler.Address == nint.Zero) + { + _charaHandler.Refresh(); + } + + if (PlayerCharacter != nint.Zero) + { + IsVisible = true; + } + } + if (!IsVisible) { Logger.LogTrace("Handler for {Ident} not visible, caching sanitized data for later", Ident); @@ -596,7 +694,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return; } - ApplyCharacterData(Guid.NewGuid(), sanitized, shouldForce); + ApplyCharacterData(Guid.NewGuid(), sanitized, forceApplyCustomization, suppressForcedModRedraw); } public bool FetchPerformanceMetricsFromCache() @@ -668,6 +766,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private void ApplyCachedMetrics(PairPerformanceMetrics metrics) { LastAppliedDataTris = metrics.TriangleCount; + LastAppliedApproximateEffectiveTris = metrics.ApproximateEffectiveTris; LastAppliedApproximateVRAMBytes = metrics.ApproximateVramBytes; LastAppliedApproximateEffectiveVRAMBytes = metrics.ApproximateEffectiveVramBytes; } @@ -675,6 +774,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private void StorePerformanceMetrics(CharacterData charaData) { if (LastAppliedDataTris < 0 + || LastAppliedApproximateEffectiveTris < 0 || LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0) { @@ -690,7 +790,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _performanceMetricsCache.StoreMetrics( Ident, dataHash, - new PairPerformanceMetrics(LastAppliedDataTris, LastAppliedApproximateVRAMBytes, LastAppliedApproximateEffectiveVRAMBytes)); + new PairPerformanceMetrics(LastAppliedDataTris, LastAppliedApproximateVRAMBytes, LastAppliedApproximateEffectiveVRAMBytes, LastAppliedApproximateEffectiveTris)); } private bool HasMissingCachedFiles(CharacterData characterData) @@ -906,7 +1006,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa SetUploading(false); } - public void ApplyCharacterData(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization = false) + public void ApplyCharacterData(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization = false, bool suppressForcedModRedraw = false) { _lastApplyAttemptAt = DateTime.UtcNow; ClearFailureState(); @@ -1000,7 +1100,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational, "Applying Character Data"))); - var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this, forceApplyCustomization, _forceApplyMods); + var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this, + forceApplyCustomization, _forceApplyMods, suppressForcedModRedraw); if (handlerReady && _forceApplyMods) { @@ -1021,7 +1122,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger.LogDebug("[BASE-{appbase}] Downloading and applying character for {name}", applicationBase, GetPrimaryAliasOrUidSafe()); var forceFullReapply = _forceFullReapply - || LastAppliedApproximateVRAMBytes < 0 || LastAppliedDataTris < 0; + || LastAppliedApproximateVRAMBytes < 0 || LastAppliedDataTris < 0 || LastAppliedApproximateEffectiveTris < 0; DownloadAndApplyCharacter(applicationBase, characterData.DeepClone(), charaDataToUpdate, forceFullReapply); } @@ -1097,12 +1198,183 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa }, CancellationToken.None); } + private void ScheduleOwnedObjectRetry(ObjectKind kind, HashSet changes) + { + if (kind == ObjectKind.Player || changes.Count == 0) + { + return; + } + + lock (_ownedRetryGate) + { + _pendingOwnedChanges[kind] = new HashSet(changes); + if (!_ownedRetryTask.IsCompleted) + { + return; + } + + _ownedRetryCts = _ownedRetryCts?.CancelRecreate() ?? new CancellationTokenSource(); + var token = _ownedRetryCts.Token; + _ownedRetryTask = Task.Run(() => OwnedObjectRetryLoopAsync(token), CancellationToken.None); + } + } + + private void ClearOwnedObjectRetry(ObjectKind kind) + { + lock (_ownedRetryGate) + { + if (!_pendingOwnedChanges.Remove(kind)) + { + return; + } + } + } + + private void ClearAllOwnedObjectRetries() + { + lock (_ownedRetryGate) + { + _pendingOwnedChanges.Clear(); + } + } + + private bool IsOwnedRetryDataStale() + { + if (!_lastDataReceivedAt.HasValue) + { + return true; + } + + return DateTime.UtcNow - _lastDataReceivedAt.Value > OwnedRetryStaleDataGrace; + } + + private async Task OwnedObjectRetryLoopAsync(CancellationToken token) + { + var delay = OwnedRetryInitialDelay; + try + { + while (!token.IsCancellationRequested) + { + if (IsOwnedRetryDataStale()) + { + ClearAllOwnedObjectRetries(); + return; + } + + Dictionary> pending; + lock (_ownedRetryGate) + { + if (_pendingOwnedChanges.Count == 0) + { + return; + } + + pending = _pendingOwnedChanges.ToDictionary(kvp => kvp.Key, kvp => new HashSet(kvp.Value)); + } + + if (!IsVisible || IsPaused() || !CanApplyNow() || PlayerCharacter == nint.Zero || _charaHandler is null) + { + await Task.Delay(delay, token).ConfigureAwait(false); + delay = IncreaseRetryDelay(delay); + continue; + } + + if ((_applicationTask?.IsCompleted ?? true) == false || (_pairDownloadTask?.IsCompleted ?? true) == false) + { + await Task.Delay(delay, token).ConfigureAwait(false); + delay = IncreaseRetryDelay(delay); + continue; + } + + var sanitized = CloneAndSanitizeLastReceived(out _); + if (sanitized is null) + { + await Task.Delay(delay, token).ConfigureAwait(false); + delay = IncreaseRetryDelay(delay); + continue; + } + + bool anyApplied = false; + foreach (var entry in pending) + { + if (!HasAppearanceDataForKind(sanitized, entry.Key)) + { + ClearOwnedObjectRetry(entry.Key); + continue; + } + + var applied = await ApplyCustomizationDataAsync(Guid.NewGuid(), entry, sanitized, token).ConfigureAwait(false); + if (applied) + { + ClearOwnedObjectRetry(entry.Key); + anyApplied = true; + } + } + + if (!anyApplied) + { + await Task.Delay(delay, token).ConfigureAwait(false); + delay = IncreaseRetryDelay(delay); + } + else + { + delay = OwnedRetryInitialDelay; + } + } + } + catch (OperationCanceledException) + { + // ignore + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Owned object retry task failed for {handler}", GetLogIdentifier()); + } + } + + private static TimeSpan IncreaseRetryDelay(TimeSpan delay) + { + var nextMs = Math.Min(delay.TotalMilliseconds * 2, OwnedRetryMaxDelay.TotalMilliseconds); + return TimeSpan.FromMilliseconds(nextMs); + } + + private static bool HasAppearanceDataForKind(CharacterData data, ObjectKind kind) + { + if (data.FileReplacements.TryGetValue(kind, out var replacements) && replacements.Count > 0) + { + return true; + } + + if (data.GlamourerData.TryGetValue(kind, out var glamourer) && !string.IsNullOrEmpty(glamourer)) + { + return true; + } + + if (data.CustomizePlusData.TryGetValue(kind, out var customize) && !string.IsNullOrEmpty(customize)) + { + return true; + } + + return false; + } + protected override void Dispose(bool disposing) { base.Dispose(disposing); SetUploading(false); var name = PlayerName; + if (!string.IsNullOrEmpty(name)) + { + _lastKnownName = name; + } + + var currentAddress = PlayerCharacter; + if (currentAddress != nint.Zero) + { + _lastKnownAddress = currentAddress; + } + var user = GetPrimaryUserDataSafe(); var alias = GetPrimaryAliasOrUidSafe(); Logger.LogDebug("Disposing {name} ({user})", name, alias); @@ -1113,6 +1385,9 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _applicationCancellationTokenSource = null; _downloadCancellationTokenSource?.CancelDispose(); _downloadCancellationTokenSource = null; + ClearAllOwnedObjectRetries(); + _ownedRetryCts?.CancelDispose(); + _ownedRetryCts = null; _downloadManager.Dispose(); _charaHandler?.Dispose(); CancelVisibilityGraceTask(); @@ -1125,43 +1400,62 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Mediator.Publish(new EventMessage(new Event(name, user, nameof(PairHandlerAdapter), EventSeverity.Informational, "Disposing User"))); } - if (_lifetime.ApplicationStopping.IsCancellationRequested) return; - - if (_dalamudUtil is { IsZoning: false, IsInCutscene: false } && !string.IsNullOrEmpty(name)) + if (IsFrameworkUnloading()) { - Logger.LogTrace("[{applicationId}] Restoring state for {name} ({user})", applicationId, name, alias); - Logger.LogDebug("[{applicationId}] Removing Temp Collection for {name} ({user})", applicationId, name, alias); - ResetPenumbraCollection(reason: nameof(Dispose)); - if (!IsVisible) + Logger.LogWarning("Framework is unloading, skipping disposal for {name} ({user})", name, alias); + return; + } + + var isStopping = _lifetime.ApplicationStopping.IsCancellationRequested; + if (isStopping) + { + ResetPenumbraCollection(reason: "DisposeStopping", awaitIpc: false); + ScheduleSafeRevertOnDisposal(applicationId, name, alias); + return; + } + + var canCleanup = !string.IsNullOrEmpty(name) + && _dalamudUtil.IsLoggedIn + && !_dalamudUtil.IsZoning + && !_dalamudUtil.IsInCutscene; + + if (!canCleanup) + { + return; + } + + Logger.LogTrace("[{applicationId}] Restoring state for {name} ({user})", applicationId, name, alias); + Logger.LogDebug("[{applicationId}] Removing Temp Collection for {name} ({user})", applicationId, name, alias); + ResetPenumbraCollection(reason: nameof(Dispose)); + if (!IsVisible) + { + Logger.LogDebug("[{applicationId}] Restoring Glamourer for {name} ({user})", applicationId, name, alias); + _ipcManager.Glamourer.RevertByNameAsync(Logger, name, applicationId).GetAwaiter().GetResult(); + } + else + { + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromSeconds(60)); + + var effectiveCachedData = _cachedData ?? _pairStateCache.TryLoad(Ident); + if (effectiveCachedData is not null) { - Logger.LogDebug("[{applicationId}] Restoring Glamourer for {name} ({user})", applicationId, name, alias); - _ipcManager.Glamourer.RevertByNameAsync(Logger, name, applicationId).GetAwaiter().GetResult(); + _cachedData = effectiveCachedData; } - else + + Logger.LogInformation("[{applicationId}] CachedData is null {isNull}, contains things: {contains}", + applicationId, _cachedData == null, _cachedData?.FileReplacements.Any() ?? false); + + foreach (KeyValuePair> item in _cachedData?.FileReplacements ?? []) { - using var cts = new CancellationTokenSource(); - cts.CancelAfter(TimeSpan.FromSeconds(60)); - - var effectiveCachedData = _cachedData ?? _pairStateCache.TryLoad(Ident); - if (effectiveCachedData is not null) + try { - _cachedData = effectiveCachedData; + RevertCustomizationDataAsync(item.Key, name, applicationId, cts.Token).GetAwaiter().GetResult(); } - - Logger.LogInformation("[{applicationId}] CachedData is null {isNull}, contains things: {contains}", - applicationId, _cachedData == null, _cachedData?.FileReplacements.Any() ?? false); - - foreach (KeyValuePair> item in _cachedData?.FileReplacements ?? []) + catch (InvalidOperationException ex) { - try - { - RevertCustomizationDataAsync(item.Key, name, applicationId, cts.Token).GetAwaiter().GetResult(); - } - catch (InvalidOperationException ex) - { - Logger.LogWarning(ex, "Failed disposing player (not present anymore?)"); - break; - } + Logger.LogWarning(ex, "Failed disposing player (not present anymore?)"); + break; } } } @@ -1174,6 +1468,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { PlayerName = null; _cachedData = null; + _lastSuccessfulDataHash = null; _lastAppliedModdedPaths = null; _needsCollectionRebuild = false; _performanceMetricsCache.Clear(Ident); @@ -1181,9 +1476,145 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } } - private async Task ApplyCustomizationDataAsync(Guid applicationId, KeyValuePair> changes, CharacterData charaData, CancellationToken token) + private bool IsFrameworkUnloading() { - if (PlayerCharacter == nint.Zero) return; + try + { + var prop = _framework.GetType().GetProperty("IsFrameworkUnloading"); + if (prop?.PropertyType == typeof(bool)) + { + return (bool)prop.GetValue(_framework)!; + } + } + catch + { + // ignore + } + + return false; + } + + private void ScheduleSafeRevertOnDisposal(Guid applicationId, string? name, string alias) + { + var cleanupName = !string.IsNullOrEmpty(name) ? name : _lastKnownName; + var cleanupAddress = _lastKnownAddress != nint.Zero + ? _lastKnownAddress + : _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Ident); + var cleanupObjectIndex = _lastKnownObjectIndex; + var cleanupIdent = Ident; + var customizeIds = _customizeIds.Values.Where(id => id.HasValue) + .Select(id => id!.Value) + .Distinct() + .ToList(); + + if (string.IsNullOrEmpty(cleanupName) + && cleanupAddress == nint.Zero + && cleanupObjectIndex == ushort.MaxValue + && customizeIds.Count == 0) + { + return; + } + + _ = Task.Run(() => SafeRevertOnDisposalAsync( + applicationId, + cleanupName, + cleanupAddress, + cleanupObjectIndex, + cleanupIdent, + customizeIds, + alias)); + } + + private async Task SafeRevertOnDisposalAsync( + Guid applicationId, + string? cleanupName, + nint cleanupAddress, + ushort cleanupObjectIndex, + string cleanupIdent, + IReadOnlyList customizeIds, + string alias) + { + try + { + if (IsFrameworkUnloading()) + { + return; + } + + if (!string.IsNullOrEmpty(cleanupName) && _ipcManager.Glamourer.APIAvailable) + { + Logger.LogDebug("[{applicationId}] Restoring Glamourer for {name} ({user})", applicationId, cleanupName, alias); + await _ipcManager.Glamourer.RevertByNameAsync(Logger, cleanupName, applicationId).ConfigureAwait(false); + } + + if (_ipcManager.CustomizePlus.APIAvailable && customizeIds.Count > 0) + { + foreach (var customizeId in customizeIds) + { + await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false); + } + } + + var address = cleanupAddress; + if (address == nint.Zero && cleanupObjectIndex != ushort.MaxValue) + { + address = await _dalamudUtil.RunOnFrameworkThread(() => + { + var obj = _dalamudUtil.GetCharacterFromObjectTableByIndex(cleanupObjectIndex); + if (obj is not Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter player) + { + return nint.Zero; + } + + if (!DalamudUtilService.TryGetHashedCID(player, out var hash) + || !string.Equals(hash, cleanupIdent, StringComparison.Ordinal)) + { + return nint.Zero; + } + + return player.Address; + }).ConfigureAwait(false); + } + + if (address == nint.Zero) + { + return; + } + + if (_ipcManager.CustomizePlus.APIAvailable) + { + await _ipcManager.CustomizePlus.RevertAsync(address).ConfigureAwait(false); + } + + if (_ipcManager.Heels.APIAvailable) + { + await _ipcManager.Heels.RestoreOffsetForPlayerAsync(address).ConfigureAwait(false); + } + + if (_ipcManager.Honorific.APIAvailable) + { + await _ipcManager.Honorific.ClearTitleAsync(address).ConfigureAwait(false); + } + + if (_ipcManager.Moodles.APIAvailable) + { + await _ipcManager.Moodles.RevertStatusAsync(address).ConfigureAwait(false); + } + + if (_ipcManager.PetNames.APIAvailable) + { + await _ipcManager.PetNames.ClearPlayerData(address).ConfigureAwait(false); + } + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed shutdown cleanup for {name}", cleanupName ?? cleanupIdent); + } + } + + private async Task ApplyCustomizationDataAsync(Guid applicationId, KeyValuePair> changes, CharacterData charaData, CancellationToken token) + { + if (PlayerCharacter == nint.Zero) return false; var ptr = PlayerCharacter; var handler = changes.Key switch @@ -1199,14 +1630,29 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { if (handler.Address == nint.Zero) { - return; + return false; } Logger.LogDebug("[{applicationId}] Applying Customization Data for {handler}", applicationId, handler); - await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handler, applicationId, 30000, token).ConfigureAwait(false); + await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false); + if (handler.ObjectKind != ObjectKind.Player + && handler.CurrentDrawCondition == GameObjectHandler.DrawCondition.DrawObjectZero) + { + Logger.LogDebug("[{applicationId}] Skipping customization apply for {handler}, draw object not available", applicationId, handler); + return false; + } + + var drawTimeoutMs = handler.ObjectKind == ObjectKind.Player ? 30000 : 5000; + var fullyLoadedTimeoutMs = handler.ObjectKind == ObjectKind.Player ? FullyLoadedTimeoutMsPlayer : FullyLoadedTimeoutMsOther; + await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handler, applicationId, drawTimeoutMs, token).ConfigureAwait(false); if (handler.Address != nint.Zero) { - await _actorObjectService.WaitForFullyLoadedAsync(handler.Address, token).ConfigureAwait(false); + var fullyLoaded = await _actorObjectService.WaitForFullyLoadedAsync(handler.Address, token, fullyLoadedTimeoutMs).ConfigureAwait(false); + if (!fullyLoaded) + { + Logger.LogDebug("[{applicationId}] Timed out waiting for {handler} to fully load, skipping customization apply", applicationId, handler); + return false; + } } token.ThrowIfCancellationRequested(); @@ -1270,6 +1716,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { await _ipcManager.Penumbra.RedrawAsync(Logger, handler, applicationId, token).ConfigureAwait(false); } + + return true; } finally { @@ -1429,6 +1877,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa try { bool skipDownscaleForPair = ShouldSkipDownscale(); + bool skipDecimationForPair = ShouldSkipDecimation(); var user = GetPrimaryUserData(); Dictionary<(string GamePath, string? Hash), string> moddedPaths; List missingReplacements = []; @@ -1467,7 +1916,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } var handlerForDownload = _charaHandler; - _pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, downloadToken, skipDownscaleForPair).ConfigureAwait(false)); + _pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, downloadToken, skipDownscaleForPair, skipDecimationForPair).ConfigureAwait(false)); await _pairDownloadTask.ConfigureAwait(false); @@ -1492,6 +1941,20 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } } + if (!skipDecimationForPair) + { + var downloadedModelHashes = toDownloadReplacements + .Where(static replacement => replacement.GamePaths.Any(static path => path.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase))) + .Select(static replacement => replacement.Hash) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (downloadedModelHashes.Count > 0) + { + await _modelDecimationService.WaitForPendingJobsAsync(downloadedModelHashes, downloadToken).ConfigureAwait(false); + } + } + toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); missingReplacements = toDownloadReplacements; @@ -1577,37 +2040,25 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa RecordFailure("Handler not available for application", "HandlerUnavailable"); return; } - _applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource(); - if (_applicationTask != null && !_applicationTask.IsCompleted) + var appToken = _applicationCancellationTokenSource?.Token; + while ((!_applicationTask?.IsCompleted ?? false) + && !downloadToken.IsCancellationRequested + && (!appToken?.IsCancellationRequested ?? false)) { - Logger.LogDebug("[BASE-{appBase}] Cancelling current data application (Id: {id}) for player ({handler})", applicationBase, _applicationId, PlayerName); - - var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(downloadToken, timeoutCts.Token); - - try - { - await _applicationTask.WaitAsync(combinedCts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - Logger.LogWarning("[BASE-{appBase}] Timeout waiting for application task {id} to complete, proceeding anyway", applicationBase, _applicationId); - } - finally - { - timeoutCts.Dispose(); - combinedCts.Dispose(); - } + Logger.LogDebug("[BASE-{appBase}] Waiting for current data application (Id: {id}) for player ({handler}) to finish", + applicationBase, _applicationId, PlayerName); + await Task.Delay(250).ConfigureAwait(false); } - if (downloadToken.IsCancellationRequested) + if (downloadToken.IsCancellationRequested || (appToken?.IsCancellationRequested ?? false)) { _forceFullReapply = true; RecordFailure("Application cancelled", "Cancellation"); return; } + _applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource(); var token = _applicationCancellationTokenSource.Token; _applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, wantsModApply, pendingModReapply, token); @@ -1630,7 +2081,17 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handlerForApply, _applicationId, 30000, token).ConfigureAwait(false); if (handlerForApply.Address != nint.Zero) { - await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token).ConfigureAwait(false); + var fullyLoaded = await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token, FullyLoadedTimeoutMsPlayer).ConfigureAwait(false); + if (!fullyLoaded) + { + Logger.LogDebug("[BASE-{applicationId}] Timed out waiting for {handler} to fully load, caching data for later application", + applicationBase, GetLogIdentifier()); + _cachedData = charaData; + _pairStateCache.Store(Ident, charaData); + _forceFullReapply = true; + RecordFailure("Actor not fully loaded within timeout", "FullyLoadedTimeout"); + return; + } } token.ThrowIfCancellationRequested(); @@ -1669,11 +2130,36 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return; } + SplitPapMappings(moddedPaths, out var withoutPap, out var papOnly); + await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, objIndex.Value).ConfigureAwait(false); - await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, _applicationId, penumbraCollection, - moddedPaths.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)).ConfigureAwait(false); - _lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(moddedPaths, moddedPaths.Comparer); + await _ipcManager.Penumbra.SetTemporaryModsAsync( + Logger, _applicationId, penumbraCollection, + withoutPap.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)) + .ConfigureAwait(false); + + await _ipcManager.Penumbra.RedrawAsync(Logger, handlerForApply, _applicationId, token).ConfigureAwait(false); + if (handlerForApply.Address != nint.Zero) + await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token).ConfigureAwait(false); + + var removedPap = await StripIncompatiblePapAsync(handlerForApply, charaData, papOnly, token).ConfigureAwait(false); + if (removedPap > 0) + { + Logger.LogTrace("[{applicationId}] Removed {removedPap} incompatible PAP mappings found for {handler}", _applicationId, removedPap, GetLogIdentifier()); + } + + var merged = new Dictionary<(string GamePath, string? Hash), string>(withoutPap, withoutPap.Comparer); + foreach (var kv in papOnly) + merged[kv.Key] = kv.Value; + + await _ipcManager.Penumbra.SetTemporaryModsAsync( + Logger, _applicationId, penumbraCollection, + merged.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)) + .ConfigureAwait(false); + + _lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(merged, merged.Comparer); + LastAppliedDataBytes = -1; foreach (var path in moddedPaths.Values.Distinct(StringComparer.OrdinalIgnoreCase).Select(v => new FileInfo(v)).Where(p => p.Exists)) { @@ -1692,7 +2178,15 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa foreach (var kind in updatedData) { - await ApplyCustomizationDataAsync(_applicationId, kind, charaData, token).ConfigureAwait(false); + var applied = await ApplyCustomizationDataAsync(_applicationId, kind, charaData, token).ConfigureAwait(false); + if (applied) + { + ClearOwnedObjectRetry(kind.Key); + } + else if (kind.Key != ObjectKind.Player) + { + ScheduleOwnedObjectRetry(kind.Key, kind.Value); + } token.ThrowIfCancellationRequested(); } @@ -1706,17 +2200,19 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _needsCollectionRebuild = false; if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0) { - _playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List()); - } - if (LastAppliedDataTris < 0) - { - await _playerPerformanceService.CheckTriangleUsageThresholds(this, charaData).ConfigureAwait(false); - } + _playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List()); + } - StorePerformanceMetrics(charaData); - _lastSuccessfulApplyAt = DateTime.UtcNow; - ClearFailureState(); - Logger.LogDebug("[{applicationId}] Application finished", _applicationId); + if (LastAppliedDataTris < 0 || LastAppliedApproximateEffectiveTris < 0) + { + await _playerPerformanceService.CheckTriangleUsageThresholds(this, charaData).ConfigureAwait(false); + } + + StorePerformanceMetrics(charaData); + _lastSuccessfulDataHash = GetDataHashSafe(charaData); + _lastSuccessfulApplyAt = DateTime.UtcNow; + ClearFailureState(); + Logger.LogDebug("[{applicationId}] Application finished", _applicationId); } catch (OperationCanceledException) { @@ -1732,19 +2228,19 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { IsVisible = false; _forceApplyMods = true; - _cachedData = charaData; - _pairStateCache.Store(Ident, charaData); - _forceFullReapply = true; - Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId); + _cachedData = charaData; + _pairStateCache.Store(Ident, charaData); + _forceFullReapply = true; + Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId); + } + else + { + Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId); + _forceFullReapply = true; + } + RecordFailure($"Application failed: {ex.Message}", "Exception"); } - else - { - Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId); - _forceFullReapply = true; - } - RecordFailure($"Application failed: {ex.Message}", "Exception"); } -} private void FrameworkUpdate() { @@ -1827,6 +2323,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { IsVisible = false; _charaHandler?.Invalidate(); + ClearAllOwnedObjectRetries(); _downloadCancellationTokenSource?.CancelDispose(); _downloadCancellationTokenSource = null; if (logChange) @@ -1839,6 +2336,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { PlayerName = name; _charaHandler = _gameObjectHandlerFactory.Create(ObjectKind.Player, () => _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Ident), isWatched: false).GetAwaiter().GetResult(); + UpdateLastKnownActor(_charaHandler.Address, name); var user = GetPrimaryUserData(); if (!string.IsNullOrEmpty(user.UID)) @@ -1953,6 +2451,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa ConcurrentDictionary<(string GamePath, string? Hash), string> outputDict = new(); bool hasMigrationChanges = false; bool skipDownscaleForPair = ShouldSkipDownscale(); + bool skipDecimationForPair = ShouldSkipDecimation(); try { @@ -1978,14 +2477,37 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (string.IsNullOrEmpty(new FileInfo(fileCache.ResolvedFilepath).Extension)) { hasMigrationChanges = true; - fileCache = _fileDbManager.MigrateFileHashToExtension(fileCache, item.GamePaths[0].Split(".")[^1]); + var anyGamePath = item.GamePaths.FirstOrDefault(); + + if (!string.IsNullOrEmpty(anyGamePath)) + { + var ext = Path.GetExtension(anyGamePath); + var extNoDot = ext.StartsWith('.') ? ext[1..] : ext; + + if (!string.IsNullOrEmpty(extNoDot)) + { + hasMigrationChanges = true; + fileCache = _fileDbManager.MigrateFileHashToExtension(fileCache, extNoDot); + } + } } foreach (var gamePath in item.GamePaths) { + var mode = _configService.Current.AnimationValidationMode; + + if (mode != AnimationValidationMode.Unsafe + && gamePath.EndsWith(".pap", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(item.Hash) + && _blockedPapHashes.ContainsKey(item.Hash)) + { + continue; + } + var preferredPath = skipDownscaleForPair ? fileCache.ResolvedFilepath : _textureDownscaleService.GetPreferredPath(item.Hash, fileCache.ResolvedFilepath); + outputDict[(gamePath, item.Hash)] = preferredPath; } } @@ -2127,6 +2649,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _cachedData = null; LastAppliedDataBytes = -1; LastAppliedDataTris = -1; + LastAppliedApproximateEffectiveTris = -1; LastAppliedApproximateVRAMBytes = -1; LastAppliedApproximateEffectiveVRAMBytes = -1; } @@ -2185,6 +2708,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (descriptor.Address == nint.Zero) return; + UpdateLastKnownActor(descriptor); RefreshTrackedHandler(descriptor); QueueActorInitialization(descriptor); } @@ -2295,7 +2819,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa HandleVisibilityLoss(logChange: false); } - private bool TryResolveDescriptorHash(ActorObjectService.ActorDescriptor descriptor, out string hashedCid) + private static bool TryResolveDescriptorHash(ActorObjectService.ActorDescriptor descriptor, out string hashedCid) { hashedCid = descriptor.HashedContentId ?? string.Empty; if (!string.IsNullOrEmpty(hashedCid)) @@ -2308,6 +2832,129 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return !string.IsNullOrEmpty(hashedCid); } + private void UpdateLastKnownActor(ActorObjectService.ActorDescriptor descriptor) + { + _lastKnownAddress = descriptor.Address; + _lastKnownObjectIndex = descriptor.ObjectIndex; + if (!string.IsNullOrEmpty(descriptor.Name)) + { + _lastKnownName = descriptor.Name; + } + } + + private void UpdateLastKnownActor(nint address, string? name) + { + if (address != nint.Zero) + { + _lastKnownAddress = address; + } + + if (!string.IsNullOrEmpty(name)) + { + _lastKnownName = name; + } + } + + private static void SplitPapMappings( + Dictionary<(string GamePath, string? Hash), string> moddedPaths, + out Dictionary<(string GamePath, string? Hash), string> withoutPap, + out Dictionary<(string GamePath, string? Hash), string> papOnly) + { + withoutPap = new(moddedPaths.Comparer); + papOnly = new(moddedPaths.Comparer); + + foreach (var kv in moddedPaths) + { + var gamePath = kv.Key.GamePath; + if (gamePath.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)) + papOnly[kv.Key] = kv.Value; + else + withoutPap[kv.Key] = kv.Value; + } + } + + private async Task StripIncompatiblePapAsync( + GameObjectHandler handlerForApply, + CharacterData charaData, + Dictionary<(string GamePath, string? Hash), string> papOnly, + CancellationToken token) + { + var mode = _configService.Current.AnimationValidationMode; + var allowBasedShift = _configService.Current.AnimationAllowOneBasedShift; + var allownNightIndex = _configService.Current.AnimationAllowNeighborIndexTolerance; + + if (mode == AnimationValidationMode.Unsafe || papOnly.Count == 0) + return 0; + + var boneIndices = await _dalamudUtil.RunOnFrameworkThread( + () => _modelAnalyzer.GetSkeletonBoneIndices(handlerForApply)) + .ConfigureAwait(false); + + if (boneIndices == null || boneIndices.Count == 0) + { + var removedCount = papOnly.Count; + papOnly.Clear(); + return removedCount; + } + + var localBoneSets = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var (rawKey, list) in boneIndices) + { + var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawKey); + if (string.IsNullOrEmpty(key)) continue; + + if (!localBoneSets.TryGetValue(key, out var set)) + localBoneSets[key] = set = []; + + foreach (var v in list) + set.Add(v); + } + + int removed = 0; + + foreach (var hash in papOnly.Keys.Select(k => k.Hash).Where(h => !string.IsNullOrEmpty(h)).Distinct(StringComparer.OrdinalIgnoreCase).ToList()) + { + token.ThrowIfCancellationRequested(); + + var papIndices = await _dalamudUtil.RunOnFrameworkThread( + () => _modelAnalyzer.GetBoneIndicesFromPap(hash!)) + .ConfigureAwait(false); + + if (papIndices == null || papIndices.Count == 0) + continue; + + if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105)) + continue; + + if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out var reason)) + continue; + + var keysToRemove = papOnly.Keys.Where(k => string.Equals(k.Hash, hash, StringComparison.OrdinalIgnoreCase)).ToList(); + foreach (var k in keysToRemove) + papOnly.Remove(k); + + removed += keysToRemove.Count; + + if (_blockedPapHashes.TryAdd(hash!, 0)) + Logger.LogWarning("Blocked remote object PAP (hash {hash}) for {handler}: {reason}", hash, GetLogIdentifier(), reason); + + if (charaData.FileReplacements.TryGetValue(ObjectKind.Player, out var list)) + { + list.RemoveAll(r => string.Equals(r.Hash, hash, StringComparison.OrdinalIgnoreCase) + && r.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase))); + } + } + + var nullHashKeys = papOnly.Keys.Where(k => string.IsNullOrEmpty(k.Hash)).ToList(); + foreach (var k in nullHashKeys) + { + papOnly.Remove(k); + removed++; + } + + return removed; + } + private async Task ApplyCustomizeAsync(nint address, string customizeData, ObjectKind kind) { _customizeIds[kind] = await _ipcManager.CustomizePlus.SetBodyScaleAsync(address, customizeData).ConfigureAwait(false); diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs index 5169820..47336eb 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs @@ -1,12 +1,15 @@ using LightlessSync.FileCache; using LightlessSync.Interop.Ipc; +using LightlessSync.LightlessConfiguration; using LightlessSync.PlayerData.Factories; using LightlessSync.Services; using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Mediator; +using LightlessSync.Services.ModelDecimation; using LightlessSync.Services.PairProcessing; using LightlessSync.Services.ServerConfiguration; using LightlessSync.Services.TextureCompression; +using Dalamud.Plugin.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -25,13 +28,18 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory private readonly IServiceProvider _serviceProvider; private readonly IHostApplicationLifetime _lifetime; private readonly FileCacheManager _fileCacheManager; + private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; private readonly PlayerPerformanceService _playerPerformanceService; private readonly PairProcessingLimiter _pairProcessingLimiter; private readonly ServerConfigurationManager _serverConfigManager; private readonly TextureDownscaleService _textureDownscaleService; + private readonly ModelDecimationService _modelDecimationService; private readonly PairStateCache _pairStateCache; private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache; private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor; + private readonly LightlessConfigService _configService; + private readonly XivDataAnalyzer _modelAnalyzer; + private readonly IFramework _framework; public PairHandlerAdapterFactory( ILoggerFactory loggerFactory, @@ -42,15 +50,20 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory FileDownloadManagerFactory fileDownloadManagerFactory, PluginWarningNotificationService pluginWarningNotificationManager, IServiceProvider serviceProvider, + IFramework framework, IHostApplicationLifetime lifetime, FileCacheManager fileCacheManager, + PlayerPerformanceConfigService playerPerformanceConfigService, PlayerPerformanceService playerPerformanceService, PairProcessingLimiter pairProcessingLimiter, ServerConfigurationManager serverConfigManager, TextureDownscaleService textureDownscaleService, + ModelDecimationService modelDecimationService, PairStateCache pairStateCache, PairPerformanceMetricsCache pairPerformanceMetricsCache, - PenumbraTempCollectionJanitor tempCollectionJanitor) + PenumbraTempCollectionJanitor tempCollectionJanitor, + XivDataAnalyzer modelAnalyzer, + LightlessConfigService configService) { _loggerFactory = loggerFactory; _mediator = mediator; @@ -60,15 +73,20 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory _fileDownloadManagerFactory = fileDownloadManagerFactory; _pluginWarningNotificationManager = pluginWarningNotificationManager; _serviceProvider = serviceProvider; + _framework = framework; _lifetime = lifetime; _fileCacheManager = fileCacheManager; + _playerPerformanceConfigService = playerPerformanceConfigService; _playerPerformanceService = playerPerformanceService; _pairProcessingLimiter = pairProcessingLimiter; _serverConfigManager = serverConfigManager; _textureDownscaleService = textureDownscaleService; + _modelDecimationService = modelDecimationService; _pairStateCache = pairStateCache; _pairPerformanceMetricsCache = pairPerformanceMetricsCache; _tempCollectionJanitor = tempCollectionJanitor; + _modelAnalyzer = modelAnalyzer; + _configService = configService; } public IPairHandlerAdapter Create(string ident) @@ -86,15 +104,20 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory downloadManager, _pluginWarningNotificationManager, dalamudUtilService, + _framework, actorObjectService, _lifetime, _fileCacheManager, + _playerPerformanceConfigService, _playerPerformanceService, _pairProcessingLimiter, _serverConfigManager, _textureDownscaleService, + _modelDecimationService, _pairStateCache, _pairPerformanceMetricsCache, - _tempCollectionJanitor); + _tempCollectionJanitor, + _modelAnalyzer, + _configService); } } diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs index ec05ee7..881c35c 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs @@ -89,7 +89,7 @@ public sealed class PairHandlerRegistry : IDisposable } if (handler.LastReceivedCharacterData is not null && - (handler.LastAppliedApproximateVRAMBytes < 0 || handler.LastAppliedDataTris < 0)) + (handler.LastAppliedApproximateVRAMBytes < 0 || handler.LastAppliedDataTris < 0 || handler.LastAppliedApproximateEffectiveTris < 0)) { handler.ApplyLastReceivedData(forced: true); } diff --git a/LightlessSync/PlayerData/Pairs/PairLedger.cs b/LightlessSync/PlayerData/Pairs/PairLedger.cs index b151e1f..fdb226e 100644 --- a/LightlessSync/PlayerData/Pairs/PairLedger.cs +++ b/LightlessSync/PlayerData/Pairs/PairLedger.cs @@ -258,7 +258,8 @@ public sealed class PairLedger : DisposableMediatorSubscriberBase if (handler.LastAppliedApproximateVRAMBytes >= 0 && handler.LastAppliedDataTris >= 0 - && handler.LastAppliedApproximateEffectiveVRAMBytes >= 0) + && handler.LastAppliedApproximateEffectiveVRAMBytes >= 0 + && handler.LastAppliedApproximateEffectiveTris >= 0) { continue; } diff --git a/LightlessSync/PlayerData/Pairs/PairPerformanceMetricsCache.cs b/LightlessSync/PlayerData/Pairs/PairPerformanceMetricsCache.cs index 110d845..5d83cee 100644 --- a/LightlessSync/PlayerData/Pairs/PairPerformanceMetricsCache.cs +++ b/LightlessSync/PlayerData/Pairs/PairPerformanceMetricsCache.cs @@ -5,7 +5,8 @@ namespace LightlessSync.PlayerData.Pairs; public readonly record struct PairPerformanceMetrics( long TriangleCount, long ApproximateVramBytes, - long ApproximateEffectiveVramBytes); + long ApproximateEffectiveVramBytes, + long ApproximateEffectiveTris); /// /// caches performance metrics keyed by pair ident diff --git a/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs b/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs index f71080a..35bf3ed 100644 --- a/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs +++ b/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs @@ -50,6 +50,7 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase }); Mediator.Subscribe(this, (_) => PushToAllVisibleUsers()); + Mediator.Subscribe(this, (msg) => HandlePairOnline(msg.PairIdent)); Mediator.Subscribe(this, (_) => { _fileTransferManager.CancelUpload(); @@ -111,6 +112,20 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase _ = PushCharacterDataAsync(forced); } + private void HandlePairOnline(PairUniqueIdentifier pairIdent) + { + if (!_apiController.IsConnected || !_pairLedger.IsPairVisible(pairIdent)) + { + return; + } + + if (_pairLedger.GetHandler(pairIdent)?.UserData is { } user) + { + _usersToPushDataTo.Add(user); + PushCharacterData(forced: true); + } + } + private async Task PushCharacterDataAsync(bool forced = false) { await _pushLock.WaitAsync(_runtimeCts.Token).ConfigureAwait(false); @@ -152,5 +167,6 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase } } - private List GetVisibleUsers() => [.. _pairLedger.GetVisiblePairs().Select(connection => connection.User)]; + private List GetVisibleUsers() + => [.. _pairLedger.GetVisiblePairs().Where(connection => connection.IsOnline).Select(connection => connection.User)]; } diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 9e80ff7..88382c6 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -40,6 +40,7 @@ using System.Reflection; using OtterTex; using LightlessSync.Services.LightFinder; using LightlessSync.Services.PairProcessing; +using LightlessSync.Services.ModelDecimation; using LightlessSync.UI.Models; namespace LightlessSync; @@ -105,6 +106,7 @@ public sealed class Plugin : IDalamudPlugin services.AddSingleton(new WindowSystem("LightlessSync")); services.AddSingleton(); services.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", string.Empty, useEmbedded: true)); + services.AddSingleton(framework); services.AddSingleton(gameGui); services.AddSingleton(gameInteropProvider); services.AddSingleton(addonLifecycle); @@ -121,10 +123,12 @@ public sealed class Plugin : IDalamudPlugin services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -177,7 +181,8 @@ public sealed class Plugin : IDalamudPlugin services.AddSingleton(sp => new BlockedCharacterHandler( sp.GetRequiredService>(), - gameInteropProvider)); + gameInteropProvider, + objectTable)); services.AddSingleton(sp => new IpcProvider( sp.GetRequiredService>(), diff --git a/LightlessSync/Services/ActorTracking/ActorObjectService.cs b/LightlessSync/Services/ActorTracking/ActorObjectService.cs index 28c5533..e443496 100644 --- a/LightlessSync/Services/ActorTracking/ActorObjectService.cs +++ b/LightlessSync/Services/ActorTracking/ActorObjectService.cs @@ -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 _playerRelatedHandlers = []; private readonly ConcurrentDictionary _activePlayers = new(); private readonly ConcurrentDictionary _gposePlayers = new(); @@ -71,6 +74,25 @@ public sealed class ActorObjectService : IHostedService, IDisposable _clientState = clientState; _condition = condition; _mediator = mediator; + + _mediator.Subscribe(this, (msg) => + { + if (!msg.OwnedObject) return; + lock (_playerRelatedHandlerLock) + { + _playerRelatedHandlers.Add(msg.GameObjectHandler); + } + RefreshTrackedActors(force: true); + }); + _mediator.Subscribe(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 PlayerDescriptors => Snapshot.PlayerDescriptors; public IReadOnlyList OwnedDescriptors => Snapshot.OwnedDescriptors; public IReadOnlyList 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 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 RenderedPlayers, IReadOnlyList RenderedCompanions, diff --git a/LightlessSync/Services/CharacterAnalyzer.cs b/LightlessSync/Services/CharacterAnalyzer.cs index 959ece3..58388ae 100644 --- a/LightlessSync/Services/CharacterAnalyzer.cs +++ b/LightlessSync/Services/CharacterAnalyzer.cs @@ -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; diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 71bdace..e399a9d 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -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; diff --git a/LightlessSync/Services/Mediator/Messages.cs b/LightlessSync/Services/Mediator/Messages.cs index ae74b78..e6db9e7 100644 --- a/LightlessSync/Services/Mediator/Messages.cs +++ b/LightlessSync/Services/Mediator/Messages.cs @@ -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 DownloadStatus) : MessageBase; +public record DownloadStartedMessage(GameObjectHandler DownloadId, IReadOnlyDictionary 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 \ No newline at end of file +#pragma warning restore MA0048 // File name must match type name diff --git a/LightlessSync/Services/ModelDecimation/MdlDecimator.cs b/LightlessSync/Services/ModelDecimation/MdlDecimator.cs new file mode 100644 index 0000000..a7af13f --- /dev/null +++ b/LightlessSync/Services/ModelDecimation/MdlDecimator.cs @@ -0,0 +1,1462 @@ +using Lumina.Data.Parsing; +using Lumina.Extensions; +using MeshDecimator; +using MeshDecimator.Algorithms; +using MeshDecimator.Math; +using Microsoft.Extensions.Logging; +using Penumbra.GameData.Files.ModelStructs; +using System.Buffers.Binary; +using MdlFile = Penumbra.GameData.Files.MdlFile; +using MsLogger = Microsoft.Extensions.Logging.ILogger; + +namespace LightlessSync.Services.ModelDecimation; + +internal static class MdlDecimator +{ + private const int MaxStreams = 3; + private const int ReadRetryCount = 8; + private const int ReadRetryDelayMs = 250; + + private static readonly HashSet SupportedUsages = + [ + MdlFile.VertexUsage.Position, + MdlFile.VertexUsage.Normal, + MdlFile.VertexUsage.Tangent1, + MdlFile.VertexUsage.UV, + MdlFile.VertexUsage.Color, + MdlFile.VertexUsage.BlendWeights, + MdlFile.VertexUsage.BlendIndices, + ]; + + private static readonly HashSet SupportedTypes = + [ + MdlFile.VertexType.Single2, + MdlFile.VertexType.Single3, + MdlFile.VertexType.Single4, + MdlFile.VertexType.Half2, + MdlFile.VertexType.Half4, + MdlFile.VertexType.UByte4, + MdlFile.VertexType.NByte4, + ]; + + public static bool TryDecimate(string sourcePath, string destinationPath, int triangleThreshold, double targetRatio, MsLogger logger) + { + try + { + if (!TryReadModelBytes(sourcePath, logger, out var data)) + { + logger.LogInformation("Skipping model decimation; source file locked or unreadable: {Path}", sourcePath); + return false; + } + var mdl = new MdlFile(data); + if (!mdl.Valid) + { + logger.LogInformation("Skipping model decimation; invalid mdl: {Path}", sourcePath); + return false; + } + + if (mdl.LodCount != 1) + { + logger.LogInformation("Skipping model decimation; unsupported LOD count for {Path}", sourcePath); + return false; + } + + if (HasShapeData(mdl)) + { + logger.LogInformation("Skipping model decimation; shape/morph data present for {Path}", sourcePath); + return false; + } + + const int lodIndex = 0; + var lod = mdl.Lods[lodIndex]; + var meshes = mdl.Meshes.ToArray(); + if (meshes.Length == 0) + { + logger.LogInformation("Skipping model decimation; no meshes for {Path}", sourcePath); + return false; + } + + if (lod.MeshCount == 0) + { + logger.LogInformation("Skipping model decimation; no meshes for {Path}", sourcePath); + return false; + } + + var lodMeshStart = (int)lod.MeshIndex; + var lodMeshEnd = lodMeshStart + lod.MeshCount; + if (lodMeshStart < 0 || lodMeshEnd > meshes.Length) + { + logger.LogInformation("Skipping model decimation; invalid LOD mesh range for {Path}", sourcePath); + return false; + } + + var anyDecimated = false; + var newSubMeshes = new List(mdl.SubMeshes.Length); + var newVertexBuffer = new List(mdl.VertexBufferSize[lodIndex] > 0 ? (int)mdl.VertexBufferSize[lodIndex] : 0); + var newIndexBuffer = new List(mdl.IndexBufferSize[lodIndex] > 0 ? (int)(mdl.IndexBufferSize[lodIndex] / sizeof(ushort)) : 0); + var subMeshCursor = 0; + DecimationAlgorithm? decimationAlgorithm = null; + int? decimationUvChannelCount = null; + + for (var meshIndex = 0; meshIndex < meshes.Length; meshIndex++) + { + var mesh = meshes[meshIndex]; + var meshSubMeshes = mdl.SubMeshes + .Skip(mesh.SubMeshIndex) + .Take(mesh.SubMeshCount) + .ToArray(); + + var meshIndexBase = newIndexBuffer.Count; + var vertexBufferBase = newVertexBuffer.Count; + + MeshStruct updatedMesh; + MdlStructs.SubmeshStruct[] updatedSubMeshes; + byte[][] vertexStreams; + int[] indices; + bool decimated; + + if (meshIndex >= lodMeshStart && meshIndex < lodMeshEnd + && TryProcessMesh(mdl, lodIndex, meshIndex, mesh, meshSubMeshes, triangleThreshold, targetRatio, + out updatedMesh, + out updatedSubMeshes, + out vertexStreams, + out indices, + out decimated, + ref decimationAlgorithm, + ref decimationUvChannelCount, + logger)) + { + updatedSubMeshes = OffsetSubMeshes(updatedSubMeshes, meshIndexBase); + } + else + { + if (meshIndex >= lodMeshStart && meshIndex < lodMeshEnd) + { + logger.LogDebug("Skipping decimation for mesh {MeshIndex} in {Path}", meshIndex, sourcePath); + } + + updatedMesh = mesh; + updatedSubMeshes = CopySubMeshes(meshSubMeshes, meshIndexBase, mesh.StartIndex); + vertexStreams = CopyVertexStreams(mdl, lodIndex, mesh); + indices = ReadIndices(mdl, lodIndex, mesh); + decimated = false; + } + + anyDecimated |= decimated; + + var vertexCount = updatedMesh.VertexCount; + var streamSizes = new int[MaxStreams]; + for (var stream = 0; stream < MaxStreams; stream++) + { + var stride = updatedMesh.VertexBufferStride(stream); + if (stride > 0 && vertexCount > 0) + { + streamSizes[stream] = stride * vertexCount; + } + } + + updatedMesh.VertexBufferOffset1 = (uint)vertexBufferBase; + updatedMesh.VertexBufferOffset2 = (uint)(vertexBufferBase + streamSizes[0]); + updatedMesh.VertexBufferOffset3 = (uint)(vertexBufferBase + streamSizes[0] + streamSizes[1]); + + newVertexBuffer.AddRange(vertexStreams[0]); + newVertexBuffer.AddRange(vertexStreams[1]); + newVertexBuffer.AddRange(vertexStreams[2]); + + updatedMesh.StartIndex = (uint)meshIndexBase; + updatedMesh.SubMeshIndex = (ushort)subMeshCursor; + updatedMesh.SubMeshCount = (ushort)updatedSubMeshes.Length; + updatedMesh.IndexCount = (uint)indices.Length; + + meshes[meshIndex] = updatedMesh; + newSubMeshes.AddRange(updatedSubMeshes); + subMeshCursor += updatedSubMeshes.Length; + newIndexBuffer.AddRange(indices.Select(static i => (ushort)i)); + } + + if (!anyDecimated) + { + logger.LogInformation("Skipping model decimation; no eligible meshes for {Path}", sourcePath); + return false; + } + + var indexBytes = BuildIndexBytes(newIndexBuffer); + + mdl.Meshes = meshes; + mdl.SubMeshes = [.. newSubMeshes]; + mdl.VertexOffset[lodIndex] = 0; + mdl.IndexOffset[lodIndex] = (uint)newVertexBuffer.Count; + mdl.VertexBufferSize[lodIndex] = (uint)newVertexBuffer.Count; + mdl.IndexBufferSize[lodIndex] = (uint)indexBytes.Length; + + mdl.Lods[lodIndex] = mdl.Lods[lodIndex] with + { + VertexDataOffset = 0, + VertexBufferSize = (uint)newVertexBuffer.Count, + IndexDataOffset = (uint)newVertexBuffer.Count, + IndexBufferSize = (uint)indexBytes.Length, + }; + + for (var clearIndex = 1; clearIndex < mdl.VertexOffset.Length; clearIndex++) + { + mdl.VertexOffset[clearIndex] = 0; + mdl.IndexOffset[clearIndex] = 0; + mdl.VertexBufferSize[clearIndex] = 0; + mdl.IndexBufferSize[clearIndex] = 0; + + if (clearIndex < mdl.Lods.Length) + { + mdl.Lods[clearIndex] = mdl.Lods[clearIndex] with + { + VertexDataOffset = 0, + VertexBufferSize = 0, + IndexDataOffset = 0, + IndexBufferSize = 0, + }; + } + } + + mdl.RemainingData = [.. newVertexBuffer, .. indexBytes]; + + var outputData = mdl.Write(); + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + var tempPath = destinationPath + ".tmp"; + File.WriteAllBytes(tempPath, outputData); + File.Move(tempPath, destinationPath, overwrite: true); + return true; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to decimate model {Path}", sourcePath); + return false; + } + } + + private static bool TryReadModelBytes(string sourcePath, MsLogger logger, out byte[] data) + { + Exception? lastError = null; + for (var attempt = 0; attempt < ReadRetryCount; attempt++) + { + try + { + data = ReadAllBytesShared(sourcePath); + return true; + } + catch (IOException ex) + { + lastError = ex; + } + catch (UnauthorizedAccessException ex) + { + lastError = ex; + } + + if (attempt < ReadRetryCount - 1) + { + Thread.Sleep(ReadRetryDelayMs); + } + } + + if (lastError != null) + { + logger.LogDebug(lastError, "Failed to read model for decimation after {Attempts} attempts: {Path}", ReadRetryCount, sourcePath); + } + + data = []; + return false; + } + + private static byte[] ReadAllBytesShared(string sourcePath) + { + using var stream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + var length = stream.Length; + if (length <= 0) + { + throw new IOException("Model file length is zero."); + } + + if (length > int.MaxValue) + { + throw new IOException("Model file too large."); + } + + var buffer = new byte[(int)length]; + var totalRead = 0; + while (totalRead < buffer.Length) + { + var read = stream.Read(buffer, totalRead, buffer.Length - totalRead); + if (read == 0) + { + break; + } + + totalRead += read; + } + + if (totalRead != buffer.Length || stream.Length != length) + { + throw new IOException("Model file length changed during read."); + } + + return buffer; + } + + private static bool TryProcessMesh( + MdlFile mdl, + int lodIndex, + int meshIndex, + MeshStruct mesh, + MdlStructs.SubmeshStruct[] meshSubMeshes, + int triangleThreshold, + double targetRatio, + out MeshStruct updatedMesh, + out MdlStructs.SubmeshStruct[] updatedSubMeshes, + out byte[][] vertexStreams, + out int[] indices, + out bool decimated, + ref DecimationAlgorithm? decimationAlgorithm, + ref int? decimationUvChannelCount, + MsLogger logger) + { + updatedMesh = mesh; + updatedSubMeshes = []; + vertexStreams = [[], [], []]; + indices = []; + decimated = false; + + if (mesh.VertexCount == 0 || mesh.IndexCount == 0) + { + return false; + } + + if (meshSubMeshes.Length == 0) + { + return false; + } + + var triangleCount = (int)(mesh.IndexCount / 3); + if (triangleCount < triangleThreshold) + { + return false; + } + + if (!TryBuildVertexFormat(mdl.VertexDeclarations[meshIndex], out var format, out var reason)) + { + logger.LogDebug("Mesh {MeshIndex} vertex format unsupported: {Reason}", meshIndex, reason); + return false; + } + + if (!TryDecodeMeshData(mdl, lodIndex, mesh, format, meshSubMeshes, out var decoded, out var subMeshIndices, out var decodeReason)) + { + logger.LogDebug("Mesh {MeshIndex} decode failed: {Reason}", meshIndex, decodeReason); + return false; + } + + var targetTriangles = (int)Math.Floor(triangleCount * targetRatio); + if (targetTriangles < 1 || targetTriangles >= triangleCount) + { + return false; + } + + var meshDecimatorMesh = BuildMesh(decoded, subMeshIndices); + var algorithm = GetOrCreateAlgorithm(format, ref decimationAlgorithm, ref decimationUvChannelCount, logger); + algorithm.Initialize(meshDecimatorMesh); + algorithm.DecimateMesh(targetTriangles); + var decimatedMesh = algorithm.ToMesh(); + + if (decimatedMesh.SubMeshCount != meshSubMeshes.Length) + { + logger.LogDebug("Mesh {MeshIndex} submesh count changed after decimation", meshIndex); + return false; + } + + if (!TryEncodeMeshData(decimatedMesh, format, mesh, meshSubMeshes, out updatedMesh, out updatedSubMeshes, out vertexStreams, out indices, out var encodeReason)) + { + logger.LogDebug("Mesh {MeshIndex} encode failed: {Reason}", meshIndex, encodeReason); + return false; + } + + decimated = true; + return true; + } + + private static DecimationAlgorithm GetOrCreateAlgorithm( + VertexFormat format, + ref DecimationAlgorithm? decimationAlgorithm, + ref int? decimationUvChannelCount, + MsLogger logger) + { + var uvChannelCount = format.UvChannelCount; + if (decimationAlgorithm == null || decimationUvChannelCount != uvChannelCount) + { + decimationAlgorithm = MeshDecimation.CreateAlgorithm(Algorithm.Default); + decimationAlgorithm.Logger = logger; + decimationUvChannelCount = uvChannelCount; + } + + return decimationAlgorithm; + } + + private static Mesh BuildMesh(DecodedMeshData decoded, int[][] subMeshIndices) + { + var mesh = new Mesh(decoded.Positions, subMeshIndices); + if (decoded.Normals != null) + { + mesh.Normals = decoded.Normals; + } + + if (decoded.Tangents != null) + { + mesh.Tangents = decoded.Tangents; + } + + if (decoded.Colors != null) + { + mesh.Colors = decoded.Colors; + } + + if (decoded.BoneWeights != null) + { + mesh.BoneWeights = decoded.BoneWeights; + } + + if (decoded.UvChannels != null) + { + for (var channel = 0; channel < decoded.UvChannels.Length; channel++) + { + mesh.SetUVs(channel, decoded.UvChannels[channel]); + } + } + + return mesh; + } + + private static bool TryDecodeMeshData( + MdlFile mdl, + int lodIndex, + MeshStruct mesh, + VertexFormat format, + MdlStructs.SubmeshStruct[] meshSubMeshes, + out DecodedMeshData decoded, + out int[][] subMeshIndices, + out string? reason) + { + decoded = default!; + subMeshIndices = []; + reason = null; + + if (!TryBuildSubMeshIndices(mdl, lodIndex, mesh, meshSubMeshes, out subMeshIndices, out reason)) + { + return false; + } + + var vertexCount = mesh.VertexCount; + var positions = new Vector3d[vertexCount]; + Vector3[]? normals = format.HasNormals ? new Vector3[vertexCount] : null; + Vector4[]? tangents = format.HasTangents ? new Vector4[vertexCount] : null; + Vector4[]? colors = format.HasColors ? new Vector4[vertexCount] : null; + BoneWeight[]? boneWeights = format.HasSkinning ? new BoneWeight[vertexCount] : null; + + Vector2[][]? uvChannels = null; + if (format.UvChannelCount > 0) + { + uvChannels = new Vector2[format.UvChannelCount][]; + for (var channel = 0; channel < format.UvChannelCount; channel++) + { + uvChannels[channel] = new Vector2[vertexCount]; + } + } + + var streams = new BinaryReader[MaxStreams]; + for (var streamIndex = 0; streamIndex < MaxStreams; streamIndex++) + { + streams[streamIndex] = new BinaryReader(new MemoryStream(mdl.RemainingData)); + streams[streamIndex].BaseStream.Position = mdl.VertexOffset[lodIndex] + mesh.VertexBufferOffset(streamIndex); + } + + var uvLookup = format.UvElements.ToDictionary(static element => ElementKey.From(element.Element), static element => element); + for (var vertexIndex = 0; vertexIndex < vertexCount; vertexIndex++) + { + byte[]? indices = null; + float[]? weights = null; + + foreach (var element in format.SortedElements) + { + var usage = (MdlFile.VertexUsage)element.Usage; + var type = (MdlFile.VertexType)element.Type; + var stream = streams[element.Stream]; + + switch (usage) + { + case MdlFile.VertexUsage.Position: + positions[vertexIndex] = ReadPosition(type, stream); + break; + case MdlFile.VertexUsage.Normal when normals != null: + normals[vertexIndex] = ReadNormal(type, stream); + break; + case MdlFile.VertexUsage.Tangent1 when tangents != null: + tangents[vertexIndex] = ReadTangent(type, stream); + break; + case MdlFile.VertexUsage.Color when colors != null: + colors[vertexIndex] = ReadColor(type, stream); + break; + case MdlFile.VertexUsage.BlendIndices: + indices = ReadIndices(type, stream); + break; + case MdlFile.VertexUsage.BlendWeights: + weights = ReadWeights(type, stream); + break; + case MdlFile.VertexUsage.UV when uvChannels != null: + if (!uvLookup.TryGetValue(ElementKey.From(element), out var uvElement)) + { + reason = "UV mapping missing."; + return false; + } + ReadUv(type, stream, uvElement, uvChannels, vertexIndex); + break; + default: + if (usage == MdlFile.VertexUsage.Normal || usage == MdlFile.VertexUsage.Tangent1 + || usage == MdlFile.VertexUsage.Color) + { + _ = ReadAndDiscard(type, stream); + } + break; + } + } + + if (boneWeights != null) + { + if (indices == null || weights == null || indices.Length != 4 || weights.Length != 4) + { + reason = "Missing or invalid skinning data."; + return false; + } + + NormalizeWeights(weights); + boneWeights[vertexIndex] = new BoneWeight(indices[0], indices[1], indices[2], indices[3], weights[0], weights[1], weights[2], weights[3]); + } + } + + decoded = new DecodedMeshData(positions, normals, tangents, colors, boneWeights, uvChannels); + return true; + } + + private static bool TryEncodeMeshData( + Mesh decimatedMesh, + VertexFormat format, + MeshStruct originalMesh, + MdlStructs.SubmeshStruct[] originalSubMeshes, + out MeshStruct updatedMesh, + out MdlStructs.SubmeshStruct[] updatedSubMeshes, + out byte[][] vertexStreams, + out int[] indices, + out string? reason) + { + updatedMesh = originalMesh; + updatedSubMeshes = []; + vertexStreams = [[], [], []]; + indices = []; + reason = null; + + var vertexCount = decimatedMesh.Vertices.Length; + if (vertexCount > ushort.MaxValue) + { + reason = "Vertex count exceeds ushort range."; + return false; + } + + var normals = decimatedMesh.Normals; + var tangents = decimatedMesh.Tangents; + var colors = decimatedMesh.Colors; + var boneWeights = decimatedMesh.BoneWeights; + + if (format.HasNormals && normals == null) + { + reason = "Missing normals after decimation."; + return false; + } + + if (format.HasTangents && tangents == null) + { + reason = "Missing tangents after decimation."; + return false; + } + + if (format.HasColors && colors == null) + { + reason = "Missing colors after decimation."; + return false; + } + + if (format.HasSkinning && boneWeights == null) + { + reason = "Missing bone weights after decimation."; + return false; + } + + var uvChannels = Array.Empty(); + if (format.UvChannelCount > 0) + { + uvChannels = new Vector2[format.UvChannelCount][]; + for (var channel = 0; channel < format.UvChannelCount; channel++) + { + if (decimatedMesh.GetUVDimension(channel) != 2) + { + reason = "Unsupported UV dimension after decimation."; + return false; + } + uvChannels[channel] = decimatedMesh.GetUVs2D(channel); + } + } + + var streamBuffers = new byte[MaxStreams][]; + for (var stream = 0; stream < MaxStreams; stream++) + { + var stride = originalMesh.VertexBufferStride(stream); + if (stride == 0 || vertexCount == 0) + { + streamBuffers[stream] = []; + continue; + } + + streamBuffers[stream] = new byte[stride * vertexCount]; + } + + var uvLookup = format.UvElements.ToDictionary(static element => ElementKey.From(element.Element), static element => element); + + foreach (var element in format.SortedElements) + { + var stride = originalMesh.VertexBufferStride(element.Stream); + if (stride == 0) + { + continue; + } + + var elementSize = GetElementSize((MdlFile.VertexType)element.Type); + if (element.Offset + elementSize > stride) + { + reason = "Vertex element stride overflow."; + return false; + } + } + + for (var vertexIndex = 0; vertexIndex < vertexCount; vertexIndex++) + { + foreach (var element in format.SortedElements) + { + var usage = (MdlFile.VertexUsage)element.Usage; + var type = (MdlFile.VertexType)element.Type; + var stream = element.Stream; + var stride = originalMesh.VertexBufferStride(stream); + if (stride == 0) + { + continue; + } + + var baseOffset = vertexIndex * stride + element.Offset; + var target = streamBuffers[stream].AsSpan(baseOffset, GetElementSize(type)); + + switch (usage) + { + case MdlFile.VertexUsage.Position: + WritePosition(type, decimatedMesh.Vertices[vertexIndex], target); + break; + case MdlFile.VertexUsage.Normal when normals != null: + WriteNormal(type, normals[vertexIndex], target); + break; + case MdlFile.VertexUsage.Tangent1 when tangents != null: + WriteTangent(type, tangents[vertexIndex], target); + break; + case MdlFile.VertexUsage.Color when colors != null: + WriteColor(type, colors[vertexIndex], target); + break; + case MdlFile.VertexUsage.BlendIndices when boneWeights != null: + WriteBlendIndices(type, boneWeights[vertexIndex], target); + break; + case MdlFile.VertexUsage.BlendWeights when boneWeights != null: + WriteBlendWeights(type, boneWeights[vertexIndex], target); + break; + case MdlFile.VertexUsage.UV when format.UvChannelCount > 0: + if (!uvLookup.TryGetValue(ElementKey.From(element), out var uvElement)) + { + reason = "UV mapping missing."; + return false; + } + WriteUv(type, uvElement, uvChannels, vertexIndex, target); + break; + } + } + } + + updatedMesh.VertexCount = (ushort)vertexCount; + + var newSubMeshes = new List(originalSubMeshes.Length); + var indexList = new List(); + + for (var subMeshIndex = 0; subMeshIndex < originalSubMeshes.Length; subMeshIndex++) + { + var subMeshIndices = decimatedMesh.GetIndices(subMeshIndex); + if (subMeshIndices.Any(index => index < 0 || index >= vertexCount)) + { + reason = "Decimated indices out of range."; + return false; + } + + var offset = indexList.Count; + indexList.AddRange(subMeshIndices); + + var updatedSubMesh = originalSubMeshes[subMeshIndex] with + { + IndexOffset = (uint)offset, + IndexCount = (uint)subMeshIndices.Length, + }; + newSubMeshes.Add(updatedSubMesh); + } + + updatedSubMeshes = newSubMeshes.ToArray(); + indices = indexList.ToArray(); + vertexStreams = streamBuffers; + return true; + } + + private static bool TryBuildSubMeshIndices( + MdlFile mdl, + int lodIndex, + MeshStruct mesh, + MdlStructs.SubmeshStruct[] meshSubMeshes, + out int[][] subMeshIndices, + out string? reason) + { + reason = null; + subMeshIndices = new int[meshSubMeshes.Length][]; + var meshIndices = ReadIndices(mdl, lodIndex, mesh); + + for (var subMeshIndex = 0; subMeshIndex < meshSubMeshes.Length; subMeshIndex++) + { + var subMesh = meshSubMeshes[subMeshIndex]; + if (subMesh.IndexCount == 0) + { + subMeshIndices[subMeshIndex] = []; + continue; + } + + var relativeOffset = (int)(subMesh.IndexOffset - mesh.StartIndex); + if (relativeOffset < 0 || relativeOffset + subMesh.IndexCount > meshIndices.Length) + { + reason = "Submesh index range out of bounds."; + return false; + } + + var slice = meshIndices.Skip(relativeOffset).Take((int)subMesh.IndexCount).Select(static i => (int)i).ToArray(); + subMeshIndices[subMeshIndex] = slice; + } + + return true; + } + + private static byte[] BuildIndexBytes(List indices) + { + var indexBytes = new byte[indices.Count * sizeof(ushort)]; + for (var i = 0; i < indices.Count; i++) + { + BinaryPrimitives.WriteUInt16LittleEndian(indexBytes.AsSpan(i * 2, 2), indices[i]); + } + + return indexBytes; + } + + private static int[] ReadIndices(MdlFile mdl, int lodIndex, MeshStruct mesh) + { + using var reader = new BinaryReader(new MemoryStream(mdl.RemainingData)); + reader.BaseStream.Position = mdl.IndexOffset[lodIndex] + mesh.StartIndex * sizeof(ushort); + var values = reader.ReadStructuresAsArray((int)mesh.IndexCount); + return values.Select(static i => (int)i).ToArray(); + } + + private static byte[][] CopyVertexStreams(MdlFile mdl, int lodIndex, MeshStruct mesh) + { + var streams = new byte[MaxStreams][]; + for (var stream = 0; stream < MaxStreams; stream++) + { + var stride = mesh.VertexBufferStride(stream); + if (stride == 0 || mesh.VertexCount == 0) + { + streams[stream] = []; + continue; + } + + var size = stride * mesh.VertexCount; + var offset = mdl.VertexOffset[lodIndex] + mesh.VertexBufferOffset(stream); + streams[stream] = mdl.RemainingData.AsSpan((int)offset, size).ToArray(); + } + + return streams; + } + + private static MdlStructs.SubmeshStruct[] CopySubMeshes(MdlStructs.SubmeshStruct[] source, int newMeshIndexBase, uint meshStartIndex) + { + var result = new MdlStructs.SubmeshStruct[source.Length]; + for (var i = 0; i < source.Length; i++) + { + var relativeOffset = (int)(source[i].IndexOffset - meshStartIndex); + result[i] = source[i] with + { + IndexOffset = (uint)(newMeshIndexBase + relativeOffset), + }; + } + + return result; + } + + private static MdlStructs.SubmeshStruct[] OffsetSubMeshes(MdlStructs.SubmeshStruct[] source, int meshIndexBase) + { + var result = new MdlStructs.SubmeshStruct[source.Length]; + for (var i = 0; i < source.Length; i++) + { + result[i] = source[i] with + { + IndexOffset = (uint)(meshIndexBase + source[i].IndexOffset), + }; + } + + return result; + } + + private static bool TryBuildVertexFormat(MdlStructs.VertexDeclarationStruct declaration, out VertexFormat format, out string? reason) + { + reason = null; + format = default!; + + var elements = declaration.VertexElements; + foreach (var element in elements) + { + if (element.Stream >= MaxStreams) + { + reason = "Vertex stream index out of range."; + return false; + } + + var usage = (MdlFile.VertexUsage)element.Usage; + var type = (MdlFile.VertexType)element.Type; + + if (!SupportedUsages.Contains(usage)) + { + reason = $"Unsupported usage {usage}."; + return false; + } + + if (!SupportedTypes.Contains(type)) + { + reason = $"Unsupported vertex type {type}."; + return false; + } + } + + var positionElements = elements.Where(static e => (MdlFile.VertexUsage)e.Usage == MdlFile.VertexUsage.Position).ToArray(); + if (positionElements.Length != 1) + { + reason = "Expected single position element."; + return false; + } + + var positionType = (MdlFile.VertexType)positionElements[0].Type; + if (positionType != MdlFile.VertexType.Single3 && positionType != MdlFile.VertexType.Single4) + { + reason = "Unsupported position element type."; + return false; + } + + var normalElements = elements.Where(static e => (MdlFile.VertexUsage)e.Usage == MdlFile.VertexUsage.Normal).ToArray(); + if (normalElements.Length > 1) + { + reason = "Multiple normal elements unsupported."; + return false; + } + + if (normalElements.Length == 1) + { + var normalType = (MdlFile.VertexType)normalElements[0].Type; + if (normalType != MdlFile.VertexType.Single3 && normalType != MdlFile.VertexType.Single4 && normalType != MdlFile.VertexType.NByte4) + { + reason = "Unsupported normal element type."; + return false; + } + } + + var tangentElements = elements.Where(static e => (MdlFile.VertexUsage)e.Usage == MdlFile.VertexUsage.Tangent1).ToArray(); + if (tangentElements.Length > 1) + { + reason = "Multiple tangent elements unsupported."; + return false; + } + + if (tangentElements.Length == 1) + { + var tangentType = (MdlFile.VertexType)tangentElements[0].Type; + if (tangentType != MdlFile.VertexType.Single4 && tangentType != MdlFile.VertexType.NByte4) + { + reason = "Unsupported tangent element type."; + return false; + } + } + + var colorElements = elements.Where(static e => (MdlFile.VertexUsage)e.Usage == MdlFile.VertexUsage.Color).ToArray(); + if (colorElements.Length > 1) + { + reason = "Multiple color elements unsupported."; + return false; + } + + MdlStructs.VertexElement? colorElement = null; + if (colorElements.Length == 1) + { + var colorType = (MdlFile.VertexType)colorElements[0].Type; + if (colorType != MdlFile.VertexType.UByte4 && colorType != MdlFile.VertexType.NByte4 && colorType != MdlFile.VertexType.Single4) + { + reason = "Unsupported color element type."; + return false; + } + + colorElement = colorElements[0]; + } + + var blendIndicesElements = elements.Where(static e => (MdlFile.VertexUsage)e.Usage == MdlFile.VertexUsage.BlendIndices).ToArray(); + var blendWeightsElements = elements.Where(static e => (MdlFile.VertexUsage)e.Usage == MdlFile.VertexUsage.BlendWeights).ToArray(); + if (blendIndicesElements.Length != blendWeightsElements.Length) + { + reason = "Blend indices/weights mismatch."; + return false; + } + + if (blendIndicesElements.Length > 1 || blendWeightsElements.Length > 1) + { + reason = "Multiple blend elements unsupported."; + return false; + } + + if (blendIndicesElements.Length == 1) + { + var indexType = (MdlFile.VertexType)blendIndicesElements[0].Type; + if (indexType != MdlFile.VertexType.UByte4) + { + reason = "Unsupported blend index type."; + return false; + } + + var weightType = (MdlFile.VertexType)blendWeightsElements[0].Type; + if (weightType != MdlFile.VertexType.UByte4 && weightType != MdlFile.VertexType.NByte4 && weightType != MdlFile.VertexType.Single4) + { + reason = "Unsupported blend weight type."; + return false; + } + } + + if (!TryBuildUvElements(elements, out var uvElements, out var uvChannelCount, out reason)) + { + return false; + } + + var sortedElements = elements.OrderBy(static element => element.Offset).ToList(); + format = new VertexFormat( + sortedElements, + normalElements.Length == 1 ? normalElements[0] : (MdlStructs.VertexElement?)null, + tangentElements.Length == 1 ? tangentElements[0] : (MdlStructs.VertexElement?)null, + colorElement, + blendIndicesElements.Length == 1 ? blendIndicesElements[0] : (MdlStructs.VertexElement?)null, + blendWeightsElements.Length == 1 ? blendWeightsElements[0] : (MdlStructs.VertexElement?)null, + uvElements, + uvChannelCount); + return true; + } + + private static bool TryBuildUvElements( + IReadOnlyList elements, + out List uvElements, + out int uvChannelCount, + out string? reason) + { + uvElements = []; + uvChannelCount = 0; + reason = null; + + var uvList = elements + .Where(static e => (MdlFile.VertexUsage)e.Usage == MdlFile.VertexUsage.UV) + .OrderBy(static e => e.UsageIndex) + .ToList(); + + foreach (var element in uvList) + { + var type = (MdlFile.VertexType)element.Type; + if (type == MdlFile.VertexType.Half2 || type == MdlFile.VertexType.Single2) + { + if (uvChannelCount + 1 > Mesh.UVChannelCount) + { + reason = "Too many UV channels."; + return false; + } + + uvElements.Add(new UvElementPacking(element, uvChannelCount, null)); + uvChannelCount += 1; + } + else if (type == MdlFile.VertexType.Half4 || type == MdlFile.VertexType.Single4) + { + if (uvChannelCount + 2 > Mesh.UVChannelCount) + { + reason = "Too many UV channels."; + return false; + } + + uvElements.Add(new UvElementPacking(element, uvChannelCount, uvChannelCount + 1)); + uvChannelCount += 2; + } + else + { + reason = "Unsupported UV type."; + return false; + } + } + + return true; + } + + private static bool HasShapeData(MdlFile mdl) + => mdl.Shapes.Length > 0 + || mdl.ShapeMeshes.Length > 0 + || mdl.ShapeValues.Length > 0 + || mdl.NeckMorphs.Length > 0; + + private static Vector3d ReadPosition(MdlFile.VertexType type, BinaryReader reader) + { + switch (type) + { + case MdlFile.VertexType.Single3: + return new Vector3d(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); + case MdlFile.VertexType.Single4: + var x = reader.ReadSingle(); + var y = reader.ReadSingle(); + var z = reader.ReadSingle(); + _ = reader.ReadSingle(); + return new Vector3d(x, y, z); + default: + throw new InvalidOperationException($"Unsupported position type {type}"); + } + } + + private static Vector3 ReadNormal(MdlFile.VertexType type, BinaryReader reader) + { + switch (type) + { + case MdlFile.VertexType.Single3: + return new Vector3(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); + case MdlFile.VertexType.Single4: + var x = reader.ReadSingle(); + var y = reader.ReadSingle(); + var z = reader.ReadSingle(); + _ = reader.ReadSingle(); + return new Vector3(x, y, z); + case MdlFile.VertexType.NByte4: + return ReadNByte4(reader).ToVector3(); + default: + throw new InvalidOperationException($"Unsupported normal type {type}"); + } + } + + private static Vector4 ReadTangent(MdlFile.VertexType type, BinaryReader reader) + { + return type switch + { + MdlFile.VertexType.Single4 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), + MdlFile.VertexType.NByte4 => ReadNByte4(reader), + _ => throw new InvalidOperationException($"Unsupported tangent type {type}"), + }; + } + + private static Vector4 ReadColor(MdlFile.VertexType type, BinaryReader reader) + { + return type switch + { + MdlFile.VertexType.UByte4 => ReadUByte4(reader), + MdlFile.VertexType.NByte4 => ReadUByte4(reader), + MdlFile.VertexType.Single4 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), + _ => throw new InvalidOperationException($"Unsupported color type {type}"), + }; + } + + private static void ReadUv(MdlFile.VertexType type, BinaryReader reader, UvElementPacking mapping, Vector2[][] uvChannels, int vertexIndex) + { + if (type == MdlFile.VertexType.Half2 || type == MdlFile.VertexType.Single2) + { + var uv = type == MdlFile.VertexType.Half2 + ? new Vector2(ReadHalf(reader), ReadHalf(reader)) + : new Vector2(reader.ReadSingle(), reader.ReadSingle()); + + uvChannels[mapping.FirstChannel][vertexIndex] = uv; + return; + } + + if (type == MdlFile.VertexType.Half4 || type == MdlFile.VertexType.Single4) + { + var uv = type == MdlFile.VertexType.Half4 + ? new Vector4(ReadHalf(reader), ReadHalf(reader), ReadHalf(reader), ReadHalf(reader)) + : new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); + + uvChannels[mapping.FirstChannel][vertexIndex] = new Vector2(uv.x, uv.y); + if (mapping.SecondChannel.HasValue) + { + uvChannels[mapping.SecondChannel.Value][vertexIndex] = new Vector2(uv.z, uv.w); + } + } + } + + private static byte[] ReadIndices(MdlFile.VertexType type, BinaryReader reader) + { + return type switch + { + MdlFile.VertexType.UByte4 => new[] { reader.ReadByte(), reader.ReadByte(), reader.ReadByte(), reader.ReadByte() }, + _ => throw new InvalidOperationException($"Unsupported indices type {type}"), + }; + } + + private static float[] ReadWeights(MdlFile.VertexType type, BinaryReader reader) + { + return type switch + { + MdlFile.VertexType.UByte4 => ReadUByte4(reader).ToFloatArray(), + MdlFile.VertexType.NByte4 => ReadUByte4(reader).ToFloatArray(), + MdlFile.VertexType.Single4 => new[] { reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle() }, + _ => throw new InvalidOperationException($"Unsupported weights type {type}"), + }; + } + + private static Vector4 ReadUByte4(BinaryReader reader) + { + return new Vector4( + reader.ReadByte() / 255f, + reader.ReadByte() / 255f, + reader.ReadByte() / 255f, + reader.ReadByte() / 255f); + } + + private static Vector4 ReadNByte4(BinaryReader reader) + { + var value = ReadUByte4(reader); + return (value * 2f) - new Vector4(1f, 1f, 1f, 1f); + } + + private static Vector4 ReadAndDiscard(MdlFile.VertexType type, BinaryReader reader) + { + return type switch + { + MdlFile.VertexType.Single2 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), 0, 0), + MdlFile.VertexType.Single3 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), 0), + MdlFile.VertexType.Single4 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), + MdlFile.VertexType.Half2 => new Vector4(ReadHalf(reader), ReadHalf(reader), 0, 0), + MdlFile.VertexType.Half4 => new Vector4(ReadHalf(reader), ReadHalf(reader), ReadHalf(reader), ReadHalf(reader)), + MdlFile.VertexType.UByte4 => ReadUByte4(reader), + MdlFile.VertexType.NByte4 => ReadUByte4(reader), + _ => Vector4.zero, + }; + } + + private static void WritePosition(MdlFile.VertexType type, Vector3d value, Span target) + { + WriteVector3(type, new Vector3((float)value.x, (float)value.y, (float)value.z), target); + } + + private static void WriteNormal(MdlFile.VertexType type, Vector3 value, Span target) + { + WriteVector3(type, value, target, normalized: type == MdlFile.VertexType.NByte4); + } + + private static void WriteTangent(MdlFile.VertexType type, Vector4 value, Span target) + { + if (type == MdlFile.VertexType.NByte4) + { + WriteNByte4(value, target); + return; + } + + WriteVector4(type, value, target); + } + + private static void WriteColor(MdlFile.VertexType type, Vector4 value, Span target) + { + if (type == MdlFile.VertexType.Single4) + { + WriteVector4(type, value, target); + return; + } + + WriteUByte4(value, target); + } + + private static void WriteBlendIndices(MdlFile.VertexType type, BoneWeight weights, Span target) + { + if (type != MdlFile.VertexType.UByte4) + { + return; + } + + target[0] = (byte)Math.Clamp(weights.boneIndex0, 0, 255); + target[1] = (byte)Math.Clamp(weights.boneIndex1, 0, 255); + target[2] = (byte)Math.Clamp(weights.boneIndex2, 0, 255); + target[3] = (byte)Math.Clamp(weights.boneIndex3, 0, 255); + } + + private static void WriteBlendWeights(MdlFile.VertexType type, BoneWeight weights, Span target) + { + if (type != MdlFile.VertexType.UByte4 && type != MdlFile.VertexType.NByte4) + { + if (type == MdlFile.VertexType.Single4) + { + BinaryPrimitives.WriteSingleLittleEndian(target[..4], weights.boneWeight0); + BinaryPrimitives.WriteSingleLittleEndian(target.Slice(4, 4), weights.boneWeight1); + BinaryPrimitives.WriteSingleLittleEndian(target.Slice(8, 4), weights.boneWeight2); + BinaryPrimitives.WriteSingleLittleEndian(target.Slice(12, 4), weights.boneWeight3); + } + return; + } + + var w0 = Clamp01(weights.boneWeight0); + var w1 = Clamp01(weights.boneWeight1); + var w2 = Clamp01(weights.boneWeight2); + var w3 = Clamp01(weights.boneWeight3); + NormalizeWeights(ref w0, ref w1, ref w2, ref w3); + + target[0] = ToByte(w0); + target[1] = ToByte(w1); + target[2] = ToByte(w2); + target[3] = ToByte(w3); + } + + private static void WriteUv(MdlFile.VertexType type, UvElementPacking mapping, Vector2[][] uvChannels, int vertexIndex, Span target) + { + if (type == MdlFile.VertexType.Half2 || type == MdlFile.VertexType.Single2) + { + var uv = uvChannels[mapping.FirstChannel][vertexIndex]; + WriteVector2(type, uv, target); + return; + } + + if (type == MdlFile.VertexType.Half4 || type == MdlFile.VertexType.Single4) + { + var uv0 = uvChannels[mapping.FirstChannel][vertexIndex]; + var uv1 = mapping.SecondChannel.HasValue + ? uvChannels[mapping.SecondChannel.Value][vertexIndex] + : Vector2.zero; + WriteVector4(type, new Vector4(uv0.x, uv0.y, uv1.x, uv1.y), target); + } + } + + private static void WriteVector2(MdlFile.VertexType type, Vector2 value, Span target) + { + if (type == MdlFile.VertexType.Single2) + { + BinaryPrimitives.WriteSingleLittleEndian(target[..4], value.x); + BinaryPrimitives.WriteSingleLittleEndian(target.Slice(4, 4), value.y); + return; + } + + if (type == MdlFile.VertexType.Half2) + { + WriteHalf(target[..2], value.x); + WriteHalf(target.Slice(2, 2), value.y); + } + } + + private static void WriteVector3(MdlFile.VertexType type, Vector3 value, Span target, bool normalized = false) + { + if (type == MdlFile.VertexType.Single3) + { + BinaryPrimitives.WriteSingleLittleEndian(target[..4], value.x); + BinaryPrimitives.WriteSingleLittleEndian(target.Slice(4, 4), value.y); + BinaryPrimitives.WriteSingleLittleEndian(target.Slice(8, 4), value.z); + return; + } + + if (type == MdlFile.VertexType.Single4) + { + BinaryPrimitives.WriteSingleLittleEndian(target[..4], value.x); + BinaryPrimitives.WriteSingleLittleEndian(target.Slice(4, 4), value.y); + BinaryPrimitives.WriteSingleLittleEndian(target.Slice(8, 4), value.z); + BinaryPrimitives.WriteSingleLittleEndian(target.Slice(12, 4), 1f); + return; + } + + if (type == MdlFile.VertexType.NByte4 && normalized) + { + WriteNByte4(new Vector4(value.x, value.y, value.z, 0f), target); + } + } + + private static void WriteVector4(MdlFile.VertexType type, Vector4 value, Span target) + { + if (type == MdlFile.VertexType.Single4) + { + BinaryPrimitives.WriteSingleLittleEndian(target[..4], value.x); + BinaryPrimitives.WriteSingleLittleEndian(target.Slice(4, 4), value.y); + BinaryPrimitives.WriteSingleLittleEndian(target.Slice(8, 4), value.z); + BinaryPrimitives.WriteSingleLittleEndian(target.Slice(12, 4), value.w); + return; + } + + if (type == MdlFile.VertexType.Half4) + { + WriteHalf(target[..2], value.x); + WriteHalf(target.Slice(2, 2), value.y); + WriteHalf(target.Slice(4, 2), value.z); + WriteHalf(target.Slice(6, 2), value.w); + return; + } + } + + private static void WriteUByte4(Vector4 value, Span target) + { + target[0] = ToByte(Clamp01(value.x)); + target[1] = ToByte(Clamp01(value.y)); + target[2] = ToByte(Clamp01(value.z)); + target[3] = ToByte(Clamp01(value.w)); + } + + private static void WriteNByte4(Vector4 value, Span target) + { + var normalized = (value * 0.5f) + new Vector4(0.5f); + WriteUByte4(normalized, target); + } + + private static void WriteHalf(Span target, float value) + { + var half = (Half)value; + BinaryPrimitives.WriteUInt16LittleEndian(target, BitConverter.HalfToUInt16Bits(half)); + } + + private static float ReadHalf(BinaryReader reader) + => (float)BitConverter.UInt16BitsToHalf(reader.ReadUInt16()); + + private static float Clamp01(float value) + => Math.Clamp(value, 0f, 1f); + + private static byte ToByte(float value) + => (byte)Math.Clamp((int)Math.Round(value * 255f), 0, 255); + + private static void NormalizeWeights(float[] weights) + { + var sum = weights.Sum(); + if (sum <= float.Epsilon) + { + return; + } + + for (var i = 0; i < weights.Length; i++) + { + weights[i] /= sum; + } + } + + private static void NormalizeWeights(ref float w0, ref float w1, ref float w2, ref float w3) + { + var sum = w0 + w1 + w2 + w3; + if (sum <= float.Epsilon) + { + return; + } + + w0 /= sum; + w1 /= sum; + w2 /= sum; + w3 /= sum; + } + + private static int GetElementSize(MdlFile.VertexType type) + => type switch + { + MdlFile.VertexType.Single2 => 8, + MdlFile.VertexType.Single3 => 12, + MdlFile.VertexType.Single4 => 16, + MdlFile.VertexType.Half2 => 4, + MdlFile.VertexType.Half4 => 8, + MdlFile.VertexType.UByte4 => 4, + MdlFile.VertexType.NByte4 => 4, + _ => throw new InvalidOperationException($"Unsupported vertex type {type}"), + }; + + private readonly record struct ElementKey(byte Stream, byte Offset, byte Type, byte Usage, byte UsageIndex) + { + public static ElementKey From(MdlStructs.VertexElement element) + => new(element.Stream, element.Offset, element.Type, element.Usage, element.UsageIndex); + } + + private sealed class VertexFormat + { + public VertexFormat( + List sortedElements, + MdlStructs.VertexElement? normalElement, + MdlStructs.VertexElement? tangentElement, + MdlStructs.VertexElement? colorElement, + MdlStructs.VertexElement? blendIndicesElement, + MdlStructs.VertexElement? blendWeightsElement, + List uvElements, + int uvChannelCount) + { + SortedElements = sortedElements; + NormalElement = normalElement; + TangentElement = tangentElement; + ColorElement = colorElement; + BlendIndicesElement = blendIndicesElement; + BlendWeightsElement = blendWeightsElement; + UvElements = uvElements; + UvChannelCount = uvChannelCount; + } + + public List SortedElements { get; } + public MdlStructs.VertexElement? NormalElement { get; } + public MdlStructs.VertexElement? TangentElement { get; } + public MdlStructs.VertexElement? ColorElement { get; } + public MdlStructs.VertexElement? BlendIndicesElement { get; } + public MdlStructs.VertexElement? BlendWeightsElement { get; } + public List UvElements { get; } + public int UvChannelCount { get; } + + public bool HasNormals => NormalElement.HasValue; + public bool HasTangents => TangentElement.HasValue; + public bool HasColors => ColorElement.HasValue; + public bool HasSkinning => BlendIndicesElement.HasValue && BlendWeightsElement.HasValue; + } + + private readonly record struct UvElementPacking(MdlStructs.VertexElement Element, int FirstChannel, int? SecondChannel); + + private sealed class DecodedMeshData + { + public DecodedMeshData( + Vector3d[] positions, + Vector3[]? normals, + Vector4[]? tangents, + Vector4[]? colors, + BoneWeight[]? boneWeights, + Vector2[][]? uvChannels) + { + Positions = positions; + Normals = normals; + Tangents = tangents; + Colors = colors; + BoneWeights = boneWeights; + UvChannels = uvChannels; + } + + public Vector3d[] Positions { get; } + public Vector3[]? Normals { get; } + public Vector4[]? Tangents { get; } + public Vector4[]? Colors { get; } + public BoneWeight[]? BoneWeights { get; } + public Vector2[][]? UvChannels { get; } + } +} + +internal static class MeshDecimatorVectorExtensions +{ + public static Vector3 ToVector3(this Vector4 value) + => new(value.x, value.y, value.z); + + public static float[] ToFloatArray(this Vector4 value) + => [value.x, value.y, value.z, value.w]; +} diff --git a/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs b/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs new file mode 100644 index 0000000..f666805 --- /dev/null +++ b/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs @@ -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 _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 _activeJobs = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _decimatedPaths = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _failedHashes = new(StringComparer.OrdinalIgnoreCase); + + public ModelDecimationService( + ILogger 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? hashes, CancellationToken token) + { + if (hashes is null) + { + return Task.CompletedTask; + } + + var pending = new List(); + var seen = new HashSet(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 + } + } +} diff --git a/LightlessSync/Services/PlayerPerformanceService.cs b/LightlessSync/Services/PlayerPerformanceService.cs index e77ccd7..5fa0049 100644 --- a/LightlessSync/Services/PlayerPerformanceService.cs +++ b/LightlessSync/Services/PlayerPerformanceService.cs @@ -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 _logger; private readonly LightlessMediator _mediator; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; + private readonly ModelDecimationService _modelDecimationService; private readonly TextureDownscaleService _textureDownscaleService; private readonly Dictionary _warnedForPlayers = new(StringComparer.Ordinal); public PlayerPerformanceService(ILogger 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 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? 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 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); -} \ No newline at end of file +} diff --git a/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs b/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs index 7a09ae7..6fa6f92 100644 --- a/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs +++ b/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs @@ -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 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); diff --git a/LightlessSync/Services/XivDataAnalyzer.cs b/LightlessSync/Services/XivDataAnalyzer.cs index 9d32883..997df16 100644 --- a/LightlessSync/Services/XivDataAnalyzer.cs +++ b/LightlessSync/Services/XivDataAnalyzer.cs @@ -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 _logger; private readonly FileCacheManager _fileCacheManager; private readonly XivDataStorageService _configService; private readonly List _failedCalculatedTris = []; + private readonly List _failedCalculatedEffectiveTris = []; public XivDataAnalyzer(ILogger logger, FileCacheManager fileCacheManager, XivDataStorageService configService) @@ -29,127 +33,441 @@ public sealed class XivDataAnalyzer 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 = []; + if (handler is null || handler.Address == nint.Zero) + return null; + + Dictionary> 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>(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>? GetBoneIndicesFromPap(string hash) + public unsafe Dictionary>? 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>(StringComparer.OrdinalIgnoreCase); - var tempHavokDataPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()) + ".hkx"; - var tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath); + var tempSets = new Dictionary>(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 { - 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>(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 available, + ushort idx, + bool papLikelyOneBased, + bool allowOneBasedShift, + bool allowNeighborTolerance) + { + Span 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> localBoneSets, + IReadOnlyDictionary> 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()) + .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 ? "" : string.Join(", ", hits)); + } + } + public async Task 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 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 cache, + List 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)(?:^|/)(?c\d{4})(?:/|$)", RegexOptions.Compiled, "en-NL")] + private static partial Regex BucketRegex(); + + [GeneratedRegex(@"(?i)\bskl_(?c\d{4})[a-z]\d{4}\b", RegexOptions.Compiled, "en-NL")] + private static partial Regex SklRegex(); + + [GeneratedRegex(@"(?i)(?c\d{4})(?!\d)", RegexOptions.Compiled, "en-NL")] + private static partial Regex LooseBucketRegex(); } diff --git a/LightlessSync/ThirdParty/MeshDecimator/Algorithms/DecimationAlgorithm.cs b/LightlessSync/ThirdParty/MeshDecimator/Algorithms/DecimationAlgorithm.cs new file mode 100644 index 0000000..723eef6 --- /dev/null +++ b/LightlessSync/ThirdParty/MeshDecimator/Algorithms/DecimationAlgorithm.cs @@ -0,0 +1,169 @@ +#region License +/* +MIT License + +Copyright(c) 2017-2018 Mattias Edlund + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +#endregion + +using System; +using Microsoft.Extensions.Logging; + +namespace MeshDecimator.Algorithms +{ + /// + /// A decimation algorithm. + /// + public abstract class DecimationAlgorithm + { + #region Delegates + /// + /// A callback for decimation status reports. + /// + /// The current iteration, starting at zero. + /// The original count of triangles. + /// The current count of triangles. + /// The target count of triangles. + public delegate void StatusReportCallback(int iteration, int originalTris, int currentTris, int targetTris); + #endregion + + #region Fields + private bool preserveBorders = false; + private int maxVertexCount = 0; + private bool verbose = false; + + private StatusReportCallback statusReportInvoker = null; + #endregion + + #region Properties + /// + /// Gets or sets if borders should be kept. + /// Default value: false + /// + [Obsolete("Use the 'DecimationAlgorithm.PreserveBorders' property instead.", false)] + public bool KeepBorders + { + get { return preserveBorders; } + set { preserveBorders = value; } + } + + /// + /// Gets or sets if borders should be preserved. + /// Default value: false + /// + public bool PreserveBorders + { + get { return preserveBorders; } + set { preserveBorders = value; } + } + + /// + /// Gets or sets if linked vertices should be kept. + /// Default value: false + /// + [Obsolete("This feature has been removed, for more details why please read the readme.", true)] + public bool KeepLinkedVertices + { + get { return false; } + set { } + } + + /// + /// Gets or sets the maximum vertex count. Set to zero for no limitation. + /// Default value: 0 (no limitation) + /// + public int MaxVertexCount + { + get { return maxVertexCount; } + set { maxVertexCount = Math.MathHelper.Max(value, 0); } + } + + /// + /// Gets or sets if verbose information should be printed in the console. + /// Default value: false + /// + public bool Verbose + { + get { return verbose; } + set { verbose = value; } + } + + /// + /// Gets or sets the logger used for diagnostics. + /// + public ILogger? Logger { get; set; } + #endregion + + #region Events + /// + /// An event for status reports for this algorithm. + /// + public event StatusReportCallback StatusReport + { + add { statusReportInvoker += value; } + remove { statusReportInvoker -= value; } + } + #endregion + + #region Protected Methods + /// + /// Reports the current status of the decimation. + /// + /// The current iteration, starting at zero. + /// The original count of triangles. + /// The current count of triangles. + /// The target count of triangles. + protected void ReportStatus(int iteration, int originalTris, int currentTris, int targetTris) + { + var statusReportInvoker = this.statusReportInvoker; + if (statusReportInvoker != null) + { + statusReportInvoker.Invoke(iteration, originalTris, currentTris, targetTris); + } + } + #endregion + + #region Public Methods + /// + /// Initializes the algorithm with the original mesh. + /// + /// The mesh. + public abstract void Initialize(Mesh mesh); + + /// + /// Decimates the mesh. + /// + /// The target triangle count. + public abstract void DecimateMesh(int targetTrisCount); + + /// + /// Decimates the mesh without losing any quality. + /// + public abstract void DecimateMeshLossless(); + + /// + /// Returns the resulting mesh. + /// + /// The resulting mesh. + public abstract Mesh ToMesh(); + #endregion + } +} diff --git a/LightlessSync/ThirdParty/MeshDecimator/Algorithms/FastQuadricMeshSimplification.cs b/LightlessSync/ThirdParty/MeshDecimator/Algorithms/FastQuadricMeshSimplification.cs new file mode 100644 index 0000000..fe22c85 --- /dev/null +++ b/LightlessSync/ThirdParty/MeshDecimator/Algorithms/FastQuadricMeshSimplification.cs @@ -0,0 +1,1549 @@ +#region License +/* +MIT License + +Copyright(c) 2017-2018 Mattias Edlund + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +#endregion + +#region Original License +///////////////////////////////////////////// +// +// Mesh Simplification Tutorial +// +// (C) by Sven Forstmann in 2014 +// +// License : MIT +// http://opensource.org/licenses/MIT +// +//https://github.com/sp4cerat/Fast-Quadric-Mesh-Simplification +#endregion + +using System; +using System.Collections.Generic; +using MeshDecimator.Collections; +using MeshDecimator.Math; +using Microsoft.Extensions.Logging; + +namespace MeshDecimator.Algorithms +{ + /// + /// The fast quadric mesh simplification algorithm. + /// + public sealed class FastQuadricMeshSimplification : DecimationAlgorithm + { + #region Consts + private const double DoubleEpsilon = 1.0E-3; + #endregion + + #region Classes + #region Triangle + private struct Triangle + { + #region Fields + public int v0; + public int v1; + public int v2; + public int subMeshIndex; + + public int va0; + public int va1; + public int va2; + + public double err0; + public double err1; + public double err2; + public double err3; + + public bool deleted; + public bool dirty; + public Vector3d n; + #endregion + + #region Properties + public int this[int index] + { + get + { + return (index == 0 ? v0 : (index == 1 ? v1 : v2)); + } + set + { + switch (index) + { + case 0: + v0 = value; + break; + case 1: + v1 = value; + break; + case 2: + v2 = value; + break; + default: + throw new IndexOutOfRangeException(); + } + } + } + #endregion + + #region Constructor + public Triangle(int v0, int v1, int v2, int subMeshIndex) + { + this.v0 = v0; + this.v1 = v1; + this.v2 = v2; + this.subMeshIndex = subMeshIndex; + + this.va0 = v0; + this.va1 = v1; + this.va2 = v2; + + err0 = err1 = err2 = err3 = 0; + deleted = dirty = false; + n = new Vector3d(); + } + #endregion + + #region Public Methods + public void GetAttributeIndices(int[] attributeIndices) + { + attributeIndices[0] = va0; + attributeIndices[1] = va1; + attributeIndices[2] = va2; + } + + public void SetAttributeIndex(int index, int value) + { + switch (index) + { + case 0: + va0 = value; + break; + case 1: + va1 = value; + break; + case 2: + va2 = value; + break; + default: + throw new IndexOutOfRangeException(); + } + } + + public void GetErrors(double[] err) + { + err[0] = err0; + err[1] = err1; + err[2] = err2; + } + #endregion + } + #endregion + + #region Vertex + private struct Vertex + { + public Vector3d p; + public int tstart; + public int tcount; + public SymmetricMatrix q; + public bool border; + public bool seam; + public bool foldover; + + public Vertex(Vector3d p) + { + this.p = p; + this.tstart = 0; + this.tcount = 0; + this.q = new SymmetricMatrix(); + this.border = true; + this.seam = false; + this.foldover = false; + } + } + #endregion + + #region Ref + private struct Ref + { + public int tid; + public int tvertex; + + public void Set(int tid, int tvertex) + { + this.tid = tid; + this.tvertex = tvertex; + } + } + #endregion + + #region Border Vertex + private struct BorderVertex + { + public int index; + public int hash; + + public BorderVertex(int index, int hash) + { + this.index = index; + this.hash = hash; + } + } + #endregion + + #region Border Vertex Comparer + private class BorderVertexComparer : IComparer + { + public static readonly BorderVertexComparer instance = new BorderVertexComparer(); + + public int Compare(BorderVertex x, BorderVertex y) + { + return x.hash.CompareTo(y.hash); + } + } + #endregion + #endregion + + #region Fields + private bool preserveSeams = false; + private bool preserveFoldovers = false; + private bool enableSmartLink = true; + private int maxIterationCount = 100; + private double agressiveness = 7.0; + private double vertexLinkDistanceSqr = double.Epsilon; + + private int subMeshCount = 0; + private ResizableArray triangles = null; + private ResizableArray vertices = null; + private ResizableArray refs = null; + + private ResizableArray vertNormals = null; + private ResizableArray vertTangents = null; + private UVChannels vertUV2D = null; + private UVChannels vertUV3D = null; + private UVChannels vertUV4D = null; + private ResizableArray vertColors = null; + private ResizableArray vertBoneWeights = null; + + private int remainingVertices = 0; + + // Pre-allocated buffers + private double[] errArr = new double[3]; + private int[] attributeIndexArr = new int[3]; + #endregion + + #region Properties + /// + /// Gets or sets if seams should be preserved. + /// Default value: false + /// + public bool PreserveSeams + { + get { return preserveSeams; } + set { preserveSeams = value; } + } + + /// + /// Gets or sets if foldovers should be preserved. + /// Default value: false + /// + public bool PreserveFoldovers + { + get { return preserveFoldovers; } + set { preserveFoldovers = value; } + } + + /// + /// Gets or sets if a feature for smarter vertex linking should be enabled, reducing artifacts in the + /// decimated result at the cost of a slightly more expensive initialization by treating vertices at + /// the same position as the same vertex while separating the attributes. + /// Default value: true + /// + public bool EnableSmartLink + { + get { return enableSmartLink; } + set { enableSmartLink = value; } + } + + /// + /// Gets or sets the maximum iteration count. Higher number is more expensive but can bring you closer to your target quality. + /// Sometimes a lower maximum count might be desired in order to lower the performance cost. + /// Default value: 100 + /// + public int MaxIterationCount + { + get { return maxIterationCount; } + set { maxIterationCount = value; } + } + + /// + /// Gets or sets the agressiveness of this algorithm. Higher number equals higher quality, but more expensive to run. + /// Default value: 7.0 + /// + public double Agressiveness + { + get { return agressiveness; } + set { agressiveness = value; } + } + + /// + /// Gets or sets the maximum squared distance between two vertices in order to link them. + /// Note that this value is only used if EnableSmartLink is true. + /// Default value: double.Epsilon + /// + public double VertexLinkDistanceSqr + { + get { return vertexLinkDistanceSqr; } + set { vertexLinkDistanceSqr = value; } + } + #endregion + + #region Constructor + /// + /// Creates a new fast quadric mesh simplification algorithm. + /// + public FastQuadricMeshSimplification() + { + triangles = new ResizableArray(0); + vertices = new ResizableArray(0); + refs = new ResizableArray(0); + } + #endregion + + #region Private Methods + #region Initialize Vertex Attribute + private ResizableArray InitializeVertexAttribute(T[] attributeValues, string attributeName) + { + if (attributeValues != null && attributeValues.Length == vertices.Length) + { + var newArray = new ResizableArray(attributeValues.Length, attributeValues.Length); + var newArrayData = newArray.Data; + Array.Copy(attributeValues, 0, newArrayData, 0, attributeValues.Length); + return newArray; + } + else if (attributeValues != null && attributeValues.Length > 0) + { + Logger?.LogError( + "Failed to set vertex attribute '{Attribute}' with {ActualLength} length of array, when {ExpectedLength} was needed.", + attributeName, + attributeValues.Length, + vertices.Length); + } + return null; + } + #endregion + + #region Calculate Error + private double VertexError(ref SymmetricMatrix q, double x, double y, double z) + { + return q.m0*x*x + 2*q.m1*x*y + 2*q.m2*x*z + 2*q.m3*x + q.m4*y*y + + 2*q.m5*y*z + 2*q.m6*y + q.m7*z*z + 2*q.m8*z + q.m9; + } + + private double CalculateError(ref Vertex vert0, ref Vertex vert1, out Vector3d result, out int resultIndex) + { + // compute interpolated vertex + SymmetricMatrix q = (vert0.q + vert1.q); + bool border = (vert0.border & vert1.border); + double error = 0.0; + double det = q.Determinant1(); + if (det != 0.0 && !border) + { + // q_delta is invertible + result = new Vector3d( + -1.0 / det * q.Determinant2(), // vx = A41/det(q_delta) + 1.0 / det * q.Determinant3(), // vy = A42/det(q_delta) + -1.0 / det * q.Determinant4()); // vz = A43/det(q_delta) + error = VertexError(ref q, result.x, result.y, result.z); + resultIndex = 2; + } + else + { + // det = 0 -> try to find best result + Vector3d p1 = vert0.p; + Vector3d p2 = vert1.p; + Vector3d p3 = (p1 + p2) * 0.5f; + double error1 = VertexError(ref q, p1.x, p1.y, p1.z); + double error2 = VertexError(ref q, p2.x, p2.y, p2.z); + double error3 = VertexError(ref q, p3.x, p3.y, p3.z); + error = MathHelper.Min(error1, error2, error3); + if (error == error3) + { + result = p3; + resultIndex = 2; + } + else if (error == error2) + { + result = p2; + resultIndex = 1; + } + else if (error == error1) + { + result = p1; + resultIndex = 0; + } + else + { + result = p3; + resultIndex = 2; + } + } + return error; + } + #endregion + + #region Flipped + /// + /// Check if a triangle flips when this edge is removed + /// + private bool Flipped(ref Vector3d p, int i0, int i1, ref Vertex v0, bool[] deleted) + { + int tcount = v0.tcount; + var refs = this.refs.Data; + var triangles = this.triangles.Data; + var vertices = this.vertices.Data; + for (int k = 0; k < tcount; k++) + { + Ref r = refs[v0.tstart + k]; + if (triangles[r.tid].deleted) + continue; + + int s = r.tvertex; + int id1 = triangles[r.tid][(s + 1) % 3]; + int id2 = triangles[r.tid][(s + 2) % 3]; + if (id1 == i1 || id2 == i1) + { + deleted[k] = true; + continue; + } + + Vector3d d1 = vertices[id1].p - p; + d1.Normalize(); + Vector3d d2 = vertices[id2].p - p; + d2.Normalize(); + double dot = Vector3d.Dot(ref d1, ref d2); + if (System.Math.Abs(dot) > 0.999) + return true; + + Vector3d n; + Vector3d.Cross(ref d1, ref d2, out n); + n.Normalize(); + deleted[k] = false; + dot = Vector3d.Dot(ref n, ref triangles[r.tid].n); + if (dot < 0.2) + return true; + } + + return false; + } + #endregion + + #region Update Triangles + /// + /// Update triangle connections and edge error after a edge is collapsed. + /// + private void UpdateTriangles(int i0, int ia0, ref Vertex v, ResizableArray deleted, ref int deletedTriangles) + { + Vector3d p; + int pIndex; + int tcount = v.tcount; + var triangles = this.triangles.Data; + var vertices = this.vertices.Data; + for (int k = 0; k < tcount; k++) + { + Ref r = refs[v.tstart + k]; + int tid = r.tid; + Triangle t = triangles[tid]; + if (t.deleted) + continue; + + if (deleted[k]) + { + triangles[tid].deleted = true; + ++deletedTriangles; + continue; + } + + t[r.tvertex] = i0; + if (ia0 != -1) + { + t.SetAttributeIndex(r.tvertex, ia0); + } + + t.dirty = true; + t.err0 = CalculateError(ref vertices[t.v0], ref vertices[t.v1], out p, out pIndex); + t.err1 = CalculateError(ref vertices[t.v1], ref vertices[t.v2], out p, out pIndex); + t.err2 = CalculateError(ref vertices[t.v2], ref vertices[t.v0], out p, out pIndex); + t.err3 = MathHelper.Min(t.err0, t.err1, t.err2); + triangles[tid] = t; + refs.Add(r); + } + } + #endregion + + #region Move/Merge Vertex Attributes + private void MoveVertexAttributes(int i0, int i1) + { + if (vertNormals != null) + { + vertNormals[i0] = vertNormals[i1]; + } + if (vertTangents != null) + { + vertTangents[i0] = vertTangents[i1]; + } + if (vertUV2D != null) + { + for (int i = 0; i < Mesh.UVChannelCount; i++) + { + var vertUV = vertUV2D[i]; + if (vertUV != null) + { + vertUV[i0] = vertUV[i1]; + } + } + } + if (vertUV3D != null) + { + for (int i = 0; i < Mesh.UVChannelCount; i++) + { + var vertUV = vertUV3D[i]; + if (vertUV != null) + { + vertUV[i0] = vertUV[i1]; + } + } + } + if (vertUV4D != null) + { + for (int i = 0; i < Mesh.UVChannelCount; i++) + { + var vertUV = vertUV4D[i]; + if (vertUV != null) + { + vertUV[i0] = vertUV[i1]; + } + } + } + if (vertColors != null) + { + vertColors[i0] = vertColors[i1]; + } + if (vertBoneWeights != null) + { + vertBoneWeights[i0] = vertBoneWeights[i1]; + } + } + + private void MergeVertexAttributes(int i0, int i1) + { + if (vertNormals != null) + { + vertNormals[i0] = (vertNormals[i0] + vertNormals[i1]) * 0.5f; + } + if (vertTangents != null) + { + vertTangents[i0] = (vertTangents[i0] + vertTangents[i1]) * 0.5f; + } + if (vertUV2D != null) + { + for (int i = 0; i < Mesh.UVChannelCount; i++) + { + var vertUV = vertUV2D[i]; + if (vertUV != null) + { + vertUV[i0] = (vertUV[i0] + vertUV[i1]) * 0.5f; + } + } + } + if (vertUV3D != null) + { + for (int i = 0; i < Mesh.UVChannelCount; i++) + { + var vertUV = vertUV3D[i]; + if (vertUV != null) + { + vertUV[i0] = (vertUV[i0] + vertUV[i1]) * 0.5f; + } + } + } + if (vertUV4D != null) + { + for (int i = 0; i < Mesh.UVChannelCount; i++) + { + var vertUV = vertUV4D[i]; + if (vertUV != null) + { + vertUV[i0] = (vertUV[i0] + vertUV[i1]) * 0.5f; + } + } + } + if (vertColors != null) + { + vertColors[i0] = (vertColors[i0] + vertColors[i1]) * 0.5f; + } + + // TODO: Do we have to blend bone weights at all or can we just keep them as it is in this scenario? + } + #endregion + + #region Are UVs The Same + private bool AreUVsTheSame(int channel, int indexA, int indexB) + { + if (vertUV2D != null) + { + var vertUV = vertUV2D[channel]; + if (vertUV != null) + { + var uvA = vertUV[indexA]; + var uvB = vertUV[indexB]; + return uvA == uvB; + } + } + + if (vertUV3D != null) + { + var vertUV = vertUV3D[channel]; + if (vertUV != null) + { + var uvA = vertUV[indexA]; + var uvB = vertUV[indexB]; + return uvA == uvB; + } + } + + if (vertUV4D != null) + { + var vertUV = vertUV4D[channel]; + if (vertUV != null) + { + var uvA = vertUV[indexA]; + var uvB = vertUV[indexB]; + return uvA == uvB; + } + } + + return false; + } + #endregion + + #region Remove Vertex Pass + /// + /// Remove vertices and mark deleted triangles + /// + private void RemoveVertexPass(int startTrisCount, int targetTrisCount, double threshold, ResizableArray deleted0, ResizableArray deleted1, ref int deletedTris) + { + var triangles = this.triangles.Data; + int triangleCount = this.triangles.Length; + var vertices = this.vertices.Data; + + bool preserveBorders = base.PreserveBorders; + int maxVertexCount = base.MaxVertexCount; + if (maxVertexCount <= 0) + maxVertexCount = int.MaxValue; + + Vector3d p; + int pIndex; + for (int tid = 0; tid < triangleCount; tid++) + { + if (triangles[tid].dirty || triangles[tid].deleted || triangles[tid].err3 > threshold) + continue; + + triangles[tid].GetErrors(errArr); + triangles[tid].GetAttributeIndices(attributeIndexArr); + for (int edgeIndex = 0; edgeIndex < 3; edgeIndex++) + { + if (errArr[edgeIndex] > threshold) + continue; + + int nextEdgeIndex = ((edgeIndex + 1) % 3); + int i0 = triangles[tid][edgeIndex]; + int i1 = triangles[tid][nextEdgeIndex]; + + // Border check + if (vertices[i0].border != vertices[i1].border) + continue; + // Seam check + else if (vertices[i0].seam != vertices[i1].seam) + continue; + // Foldover check + else if (vertices[i0].foldover != vertices[i1].foldover) + continue; + // If borders should be preserved + else if (preserveBorders && vertices[i0].border) + continue; + // If seams should be preserved + else if (preserveSeams && vertices[i0].seam) + continue; + // If foldovers should be preserved + else if (preserveFoldovers && vertices[i0].foldover) + continue; + + // Compute vertex to collapse to + CalculateError(ref vertices[i0], ref vertices[i1], out p, out pIndex); + deleted0.Resize(vertices[i0].tcount); // normals temporarily + deleted1.Resize(vertices[i1].tcount); // normals temporarily + + // Don't remove if flipped + if (Flipped(ref p, i0, i1, ref vertices[i0], deleted0.Data)) + continue; + if (Flipped(ref p, i1, i0, ref vertices[i1], deleted1.Data)) + continue; + + int ia0 = attributeIndexArr[edgeIndex]; + + // Not flipped, so remove edge + vertices[i0].p = p; + vertices[i0].q += vertices[i1].q; + + if (pIndex == 1) + { + // Move vertex attributes from ia1 to ia0 + int ia1 = attributeIndexArr[nextEdgeIndex]; + MoveVertexAttributes(ia0, ia1); + } + else if (pIndex == 2) + { + // Merge vertex attributes ia0 and ia1 into ia0 + int ia1 = attributeIndexArr[nextEdgeIndex]; + MergeVertexAttributes(ia0, ia1); + } + + if (vertices[i0].seam) + { + ia0 = -1; + } + + int tstart = refs.Length; + UpdateTriangles(i0, ia0, ref vertices[i0], deleted0, ref deletedTris); + UpdateTriangles(i0, ia0, ref vertices[i1], deleted1, ref deletedTris); + + int tcount = refs.Length - tstart; + if (tcount <= vertices[i0].tcount) + { + // save ram + if (tcount > 0) + { + var refsArr = refs.Data; + Array.Copy(refsArr, tstart, refsArr, vertices[i0].tstart, tcount); + } + } + else + { + // append + vertices[i0].tstart = tstart; + } + + vertices[i0].tcount = tcount; + --remainingVertices; + break; + } + + // Check if we are already done + if ((startTrisCount - deletedTris) <= targetTrisCount && remainingVertices < maxVertexCount) + break; + } + } + #endregion + + #region Update Mesh + /// + /// Compact triangles, compute edge error and build reference list. + /// + /// The iteration index. + private void UpdateMesh(int iteration) + { + var triangles = this.triangles.Data; + var vertices = this.vertices.Data; + + int triangleCount = this.triangles.Length; + int vertexCount = this.vertices.Length; + if (iteration > 0) // compact triangles + { + int dst = 0; + for (int i = 0; i < triangleCount; i++) + { + if (!triangles[i].deleted) + { + if (dst != i) + { + triangles[dst] = triangles[i]; + } + dst++; + } + } + this.triangles.Resize(dst); + triangles = this.triangles.Data; + triangleCount = dst; + } + + UpdateReferences(); + + // Identify boundary : vertices[].border=0,1 + if (iteration == 0) + { + var refs = this.refs.Data; + + var vcount = new List(8); + var vids = new List(8); + int vsize = 0; + for (int i = 0; i < vertexCount; i++) + { + vertices[i].border = false; + vertices[i].seam = false; + vertices[i].foldover = false; + } + + int ofs; + int id; + int borderVertexCount = 0; + double borderMinX = double.MaxValue; + double borderMaxX = double.MinValue; + for (int i = 0; i < vertexCount; i++) + { + int tstart = vertices[i].tstart; + int tcount = vertices[i].tcount; + vcount.Clear(); + vids.Clear(); + vsize = 0; + + for (int j = 0; j < tcount; j++) + { + int tid = refs[tstart + j].tid; + for (int k = 0; k < 3; k++) + { + ofs = 0; + id = triangles[tid][k]; + while (ofs < vsize) + { + if (vids[ofs] == id) + break; + + ++ofs; + } + + if (ofs == vsize) + { + vcount.Add(1); + vids.Add(id); + ++vsize; + } + else + { + ++vcount[ofs]; + } + } + } + + for (int j = 0; j < vsize; j++) + { + if (vcount[j] == 1) + { + id = vids[j]; + vertices[id].border = true; + ++borderVertexCount; + + if (enableSmartLink) + { + if (vertices[id].p.x < borderMinX) + { + borderMinX = vertices[id].p.x; + } + if (vertices[id].p.x > borderMaxX) + { + borderMaxX = vertices[id].p.x; + } + } + } + } + } + + if (enableSmartLink) + { + // First find all border vertices + var borderVertices = new BorderVertex[borderVertexCount]; + int borderIndexCount = 0; + double borderAreaWidth = borderMaxX - borderMinX; + for (int i = 0; i < vertexCount; i++) + { + if (vertices[i].border) + { + int vertexHash = (int)(((((vertices[i].p.x - borderMinX) / borderAreaWidth) * 2.0) - 1.0) * int.MaxValue); + borderVertices[borderIndexCount] = new BorderVertex(i, vertexHash); + ++borderIndexCount; + } + } + + // Sort the border vertices by hash + Array.Sort(borderVertices, 0, borderIndexCount, BorderVertexComparer.instance); + + // Calculate the maximum hash distance based on the maximum vertex link distance + double vertexLinkDistance = System.Math.Sqrt(vertexLinkDistanceSqr); + int hashMaxDistance = System.Math.Max((int)((vertexLinkDistance / borderAreaWidth) * int.MaxValue), 1); + + // Then find identical border vertices and bind them together as one + for (int i = 0; i < borderIndexCount; i++) + { + int myIndex = borderVertices[i].index; + if (myIndex == -1) + continue; + + var myPoint = vertices[myIndex].p; + for (int j = i + 1; j < borderIndexCount; j++) + { + int otherIndex = borderVertices[j].index; + if (otherIndex == -1) + continue; + else if ((borderVertices[j].hash - borderVertices[i].hash) > hashMaxDistance) // There is no point to continue beyond this point + break; + + var otherPoint = vertices[otherIndex].p; + var sqrX = ((myPoint.x - otherPoint.x) * (myPoint.x - otherPoint.x)); + var sqrY = ((myPoint.y - otherPoint.y) * (myPoint.y - otherPoint.y)); + var sqrZ = ((myPoint.z - otherPoint.z) * (myPoint.z - otherPoint.z)); + var sqrMagnitude = sqrX + sqrY + sqrZ; + + if (sqrMagnitude <= vertexLinkDistanceSqr) + { + borderVertices[j].index = -1; // NOTE: This makes sure that the "other" vertex is not processed again + vertices[myIndex].border = false; + vertices[otherIndex].border = false; + + if (AreUVsTheSame(0, myIndex, otherIndex)) + { + vertices[myIndex].foldover = true; + vertices[otherIndex].foldover = true; + } + else + { + vertices[myIndex].seam = true; + vertices[otherIndex].seam = true; + } + + int otherTriangleCount = vertices[otherIndex].tcount; + int otherTriangleStart = vertices[otherIndex].tstart; + for (int k = 0; k < otherTriangleCount; k++) + { + var r = refs[otherTriangleStart + k]; + triangles[r.tid][r.tvertex] = myIndex; + } + } + } + } + + // Update the references again + UpdateReferences(); + } + + // Init Quadrics by Plane & Edge Errors + // + // required at the beginning ( iteration == 0 ) + // recomputing during the simplification is not required, + // but mostly improves the result for closed meshes + for (int i = 0; i < vertexCount; i++) + { + vertices[i].q = new SymmetricMatrix(); + } + + int v0, v1, v2; + Vector3d n, p0, p1, p2, p10, p20, dummy; + int dummy2; + SymmetricMatrix sm; + for (int i = 0; i < triangleCount; i++) + { + v0 = triangles[i].v0; + v1 = triangles[i].v1; + v2 = triangles[i].v2; + + p0 = vertices[v0].p; + p1 = vertices[v1].p; + p2 = vertices[v2].p; + p10 = p1 - p0; + p20 = p2 - p0; + Vector3d.Cross(ref p10, ref p20, out n); + n.Normalize(); + triangles[i].n = n; + + sm = new SymmetricMatrix(n.x, n.y, n.z, -Vector3d.Dot(ref n, ref p0)); + vertices[v0].q += sm; + vertices[v1].q += sm; + vertices[v2].q += sm; + } + + for (int i = 0; i < triangleCount; i++) + { + // Calc Edge Error + var triangle = triangles[i]; + triangles[i].err0 = CalculateError(ref vertices[triangle.v0], ref vertices[triangle.v1], out dummy, out dummy2); + triangles[i].err1 = CalculateError(ref vertices[triangle.v1], ref vertices[triangle.v2], out dummy, out dummy2); + triangles[i].err2 = CalculateError(ref vertices[triangle.v2], ref vertices[triangle.v0], out dummy, out dummy2); + triangles[i].err3 = MathHelper.Min(triangles[i].err0, triangles[i].err1, triangles[i].err2); + } + } + } + #endregion + + #region Update References + private void UpdateReferences() + { + int triangleCount = this.triangles.Length; + int vertexCount = this.vertices.Length; + var triangles = this.triangles.Data; + var vertices = this.vertices.Data; + + // Init Reference ID list + for (int i = 0; i < vertexCount; i++) + { + vertices[i].tstart = 0; + vertices[i].tcount = 0; + } + + for (int i = 0; i < triangleCount; i++) + { + ++vertices[triangles[i].v0].tcount; + ++vertices[triangles[i].v1].tcount; + ++vertices[triangles[i].v2].tcount; + } + + int tstart = 0; + remainingVertices = 0; + for (int i = 0; i < vertexCount; i++) + { + vertices[i].tstart = tstart; + if (vertices[i].tcount > 0) + { + tstart += vertices[i].tcount; + vertices[i].tcount = 0; + ++remainingVertices; + } + } + + // Write References + this.refs.Resize(tstart); + var refs = this.refs.Data; + for (int i = 0; i < triangleCount; i++) + { + int v0 = triangles[i].v0; + int v1 = triangles[i].v1; + int v2 = triangles[i].v2; + int start0 = vertices[v0].tstart; + int count0 = vertices[v0].tcount; + int start1 = vertices[v1].tstart; + int count1 = vertices[v1].tcount; + int start2 = vertices[v2].tstart; + int count2 = vertices[v2].tcount; + + refs[start0 + count0].Set(i, 0); + refs[start1 + count1].Set(i, 1); + refs[start2 + count2].Set(i, 2); + + ++vertices[v0].tcount; + ++vertices[v1].tcount; + ++vertices[v2].tcount; + } + } + #endregion + + #region Compact Mesh + /// + /// Finally compact mesh before exiting. + /// + private void CompactMesh() + { + int dst = 0; + var vertices = this.vertices.Data; + int vertexCount = this.vertices.Length; + for (int i = 0; i < vertexCount; i++) + { + vertices[i].tcount = 0; + } + + var vertNormals = (this.vertNormals != null ? this.vertNormals.Data : null); + var vertTangents = (this.vertTangents != null ? this.vertTangents.Data : null); + var vertUV2D = (this.vertUV2D != null ? this.vertUV2D.Data : null); + var vertUV3D = (this.vertUV3D != null ? this.vertUV3D.Data : null); + var vertUV4D = (this.vertUV4D != null ? this.vertUV4D.Data : null); + var vertColors = (this.vertColors != null ? this.vertColors.Data : null); + var vertBoneWeights = (this.vertBoneWeights != null ? this.vertBoneWeights.Data : null); + + var triangles = this.triangles.Data; + int triangleCount = this.triangles.Length; + for (int i = 0; i < triangleCount; i++) + { + var triangle = triangles[i]; + if (!triangle.deleted) + { + if (triangle.va0 != triangle.v0) + { + int iDest = triangle.va0; + int iSrc = triangle.v0; + vertices[iDest].p = vertices[iSrc].p; + if (vertBoneWeights != null) + { + vertBoneWeights[iDest] = vertBoneWeights[iSrc]; + } + triangle.v0 = triangle.va0; + } + if (triangle.va1 != triangle.v1) + { + int iDest = triangle.va1; + int iSrc = triangle.v1; + vertices[iDest].p = vertices[iSrc].p; + if (vertBoneWeights != null) + { + vertBoneWeights[iDest] = vertBoneWeights[iSrc]; + } + triangle.v1 = triangle.va1; + } + if (triangle.va2 != triangle.v2) + { + int iDest = triangle.va2; + int iSrc = triangle.v2; + vertices[iDest].p = vertices[iSrc].p; + if (vertBoneWeights != null) + { + vertBoneWeights[iDest] = vertBoneWeights[iSrc]; + } + triangle.v2 = triangle.va2; + } + + triangles[dst++] = triangle; + + vertices[triangle.v0].tcount = 1; + vertices[triangle.v1].tcount = 1; + vertices[triangle.v2].tcount = 1; + } + } + + triangleCount = dst; + this.triangles.Resize(triangleCount); + triangles = this.triangles.Data; + + dst = 0; + for (int i = 0; i < vertexCount; i++) + { + var vert = vertices[i]; + if (vert.tcount > 0) + { + vert.tstart = dst; + vertices[i] = vert; + + if (dst != i) + { + vertices[dst].p = vert.p; + if (vertNormals != null) vertNormals[dst] = vertNormals[i]; + if (vertTangents != null) vertTangents[dst] = vertTangents[i]; + if (vertUV2D != null) + { + for (int j = 0; j < Mesh.UVChannelCount; j++) + { + var vertUV = vertUV2D[j]; + if (vertUV != null) + { + vertUV[dst] = vertUV[i]; + } + } + } + if (vertUV3D != null) + { + for (int j = 0; j < Mesh.UVChannelCount; j++) + { + var vertUV = vertUV3D[j]; + if (vertUV != null) + { + vertUV[dst] = vertUV[i]; + } + } + } + if (vertUV4D != null) + { + for (int j = 0; j < Mesh.UVChannelCount; j++) + { + var vertUV = vertUV4D[j]; + if (vertUV != null) + { + vertUV[dst] = vertUV[i]; + } + } + } + if (vertColors != null) vertColors[dst] = vertColors[i]; + if (vertBoneWeights != null) vertBoneWeights[dst] = vertBoneWeights[i]; + } + ++dst; + } + } + + for (int i = 0; i < triangleCount; i++) + { + var triangle = triangles[i]; + triangle.v0 = vertices[triangle.v0].tstart; + triangle.v1 = vertices[triangle.v1].tstart; + triangle.v2 = vertices[triangle.v2].tstart; + triangles[i] = triangle; + } + + vertexCount = dst; + this.vertices.Resize(vertexCount); + if (vertNormals != null) this.vertNormals.Resize(vertexCount, true); + if (vertTangents != null) this.vertTangents.Resize(vertexCount, true); + if (vertUV2D != null) this.vertUV2D.Resize(vertexCount, true); + if (vertUV3D != null) this.vertUV3D.Resize(vertexCount, true); + if (vertUV4D != null) this.vertUV4D.Resize(vertexCount, true); + if (vertColors != null) this.vertColors.Resize(vertexCount, true); + if (vertBoneWeights != null) this.vertBoneWeights.Resize(vertexCount, true); + } + #endregion + #endregion + + #region Public Methods + #region Initialize + /// + /// Initializes the algorithm with the original mesh. + /// + /// The mesh. + public override void Initialize(Mesh mesh) + { + if (mesh == null) + throw new ArgumentNullException("mesh"); + + int meshSubMeshCount = mesh.SubMeshCount; + int meshTriangleCount = mesh.TriangleCount; + var meshVertices = mesh.Vertices; + var meshNormals = mesh.Normals; + var meshTangents = mesh.Tangents; + var meshColors = mesh.Colors; + var meshBoneWeights = mesh.BoneWeights; + subMeshCount = meshSubMeshCount; + + vertices.Resize(meshVertices.Length); + var vertArr = vertices.Data; + for (int i = 0; i < meshVertices.Length; i++) + { + vertArr[i] = new Vertex(meshVertices[i]); + } + + triangles.Resize(meshTriangleCount); + var trisArr = triangles.Data; + int triangleIndex = 0; + for (int subMeshIndex = 0; subMeshIndex < meshSubMeshCount; subMeshIndex++) + { + int[] subMeshIndices = mesh.GetIndices(subMeshIndex); + int subMeshTriangleCount = subMeshIndices.Length / 3; + for (int i = 0; i < subMeshTriangleCount; i++) + { + int offset = i * 3; + int v0 = subMeshIndices[offset]; + int v1 = subMeshIndices[offset + 1]; + int v2 = subMeshIndices[offset + 2]; + trisArr[triangleIndex++] = new Triangle(v0, v1, v2, subMeshIndex); + } + } + + vertNormals = InitializeVertexAttribute(meshNormals, "normals"); + vertTangents = InitializeVertexAttribute(meshTangents, "tangents"); + vertColors = InitializeVertexAttribute(meshColors, "colors"); + vertBoneWeights = InitializeVertexAttribute(meshBoneWeights, "boneWeights"); + + for (int i = 0; i < Mesh.UVChannelCount; i++) + { + int uvDim = mesh.GetUVDimension(i); + string uvAttributeName = string.Format("uv{0}", i); + if (uvDim == 2) + { + if (vertUV2D == null) + vertUV2D = new UVChannels(); + + var uvs = mesh.GetUVs2D(i); + vertUV2D[i] = InitializeVertexAttribute(uvs, uvAttributeName); + } + else if (uvDim == 3) + { + if (vertUV3D == null) + vertUV3D = new UVChannels(); + + var uvs = mesh.GetUVs3D(i); + vertUV3D[i] = InitializeVertexAttribute(uvs, uvAttributeName); + } + else if (uvDim == 4) + { + if (vertUV4D == null) + vertUV4D = new UVChannels(); + + var uvs = mesh.GetUVs4D(i); + vertUV4D[i] = InitializeVertexAttribute(uvs, uvAttributeName); + } + } + } + #endregion + + #region Decimate Mesh + /// + /// Decimates the mesh. + /// + /// The target triangle count. + public override void DecimateMesh(int targetTrisCount) + { + if (targetTrisCount < 0) + throw new ArgumentOutOfRangeException("targetTrisCount"); + + int deletedTris = 0; + ResizableArray deleted0 = new ResizableArray(20); + ResizableArray deleted1 = new ResizableArray(20); + var triangles = this.triangles.Data; + int triangleCount = this.triangles.Length; + int startTrisCount = triangleCount; + var vertices = this.vertices.Data; + + int maxVertexCount = base.MaxVertexCount; + if (maxVertexCount <= 0) + maxVertexCount = int.MaxValue; + + for (int iteration = 0; iteration < maxIterationCount; iteration++) + { + ReportStatus(iteration, startTrisCount, (startTrisCount - deletedTris), targetTrisCount); + if ((startTrisCount - deletedTris) <= targetTrisCount && remainingVertices < maxVertexCount) + break; + + // Update mesh once in a while + if ((iteration % 5) == 0) + { + UpdateMesh(iteration); + triangles = this.triangles.Data; + triangleCount = this.triangles.Length; + vertices = this.vertices.Data; + } + + // Clear dirty flag + for (int i = 0; i < triangleCount; i++) + { + triangles[i].dirty = false; + } + + // All triangles with edges below the threshold will be removed + // + // The following numbers works well for most models. + // If it does not, try to adjust the 3 parameters + double threshold = 0.000000001 * System.Math.Pow(iteration + 3, agressiveness); + + if (Verbose && (iteration % 5) == 0) + { + Logger?.LogTrace( + "Iteration {Iteration} - triangles {Triangles} threshold {Threshold}", + iteration, + (startTrisCount - deletedTris), + threshold); + } + + // Remove vertices & mark deleted triangles + RemoveVertexPass(startTrisCount, targetTrisCount, threshold, deleted0, deleted1, ref deletedTris); + } + + CompactMesh(); + } + #endregion + + #region Decimate Mesh Lossless + /// + /// Decimates the mesh without losing any quality. + /// + public override void DecimateMeshLossless() + { + int deletedTris = 0; + ResizableArray deleted0 = new ResizableArray(0); + ResizableArray deleted1 = new ResizableArray(0); + var triangles = this.triangles.Data; + int triangleCount = this.triangles.Length; + int startTrisCount = triangleCount; + var vertices = this.vertices.Data; + + ReportStatus(0, startTrisCount, startTrisCount, -1); + for (int iteration = 0; iteration < 9999; iteration++) + { + // Update mesh constantly + UpdateMesh(iteration); + triangles = this.triangles.Data; + triangleCount = this.triangles.Length; + vertices = this.vertices.Data; + + ReportStatus(iteration, startTrisCount, triangleCount, -1); + + // Clear dirty flag + for (int i = 0; i < triangleCount; i++) + { + triangles[i].dirty = false; + } + + // All triangles with edges below the threshold will be removed + // + // The following numbers works well for most models. + // If it does not, try to adjust the 3 parameters + double threshold = DoubleEpsilon; + + if (Verbose) + { + Logger?.LogTrace("Lossless iteration {Iteration}", iteration); + } + + // Remove vertices & mark deleted triangles + RemoveVertexPass(startTrisCount, 0, threshold, deleted0, deleted1, ref deletedTris); + + if (deletedTris <= 0) + break; + + deletedTris = 0; + } + + CompactMesh(); + } + #endregion + + #region To Mesh + /// + /// Returns the resulting mesh. + /// + /// The resulting mesh. + public override Mesh ToMesh() + { + int vertexCount = this.vertices.Length; + int triangleCount = this.triangles.Length; + var vertices = new Vector3d[vertexCount]; + var indices = new int[subMeshCount][]; + + var vertArr = this.vertices.Data; + for (int i = 0; i < vertexCount; i++) + { + vertices[i] = vertArr[i].p; + } + + // First get the sub-mesh offsets + var triArr = this.triangles.Data; + int[] subMeshOffsets = new int[subMeshCount]; + int lastSubMeshOffset = -1; + for (int i = 0; i < triangleCount; i++) + { + var triangle = triArr[i]; + if (triangle.subMeshIndex != lastSubMeshOffset) + { + for (int j = lastSubMeshOffset + 1; j < triangle.subMeshIndex; j++) + { + subMeshOffsets[j] = i; + } + subMeshOffsets[triangle.subMeshIndex] = i; + lastSubMeshOffset = triangle.subMeshIndex; + } + } + for (int i = lastSubMeshOffset + 1; i < subMeshCount; i++) + { + subMeshOffsets[i] = triangleCount; + } + + // Then setup the sub-meshes + for (int subMeshIndex = 0; subMeshIndex < subMeshCount; subMeshIndex++) + { + int startOffset = subMeshOffsets[subMeshIndex]; + if (startOffset < triangleCount) + { + int endOffset = ((subMeshIndex + 1) < subMeshCount ? subMeshOffsets[subMeshIndex + 1] : triangleCount); + int subMeshTriangleCount = endOffset - startOffset; + if (subMeshTriangleCount < 0) subMeshTriangleCount = 0; + int[] subMeshIndices = new int[subMeshTriangleCount * 3]; + + for (int triangleIndex = startOffset; triangleIndex < endOffset; triangleIndex++) + { + var triangle = triArr[triangleIndex]; + int offset = (triangleIndex - startOffset) * 3; + subMeshIndices[offset] = triangle.v0; + subMeshIndices[offset + 1] = triangle.v1; + subMeshIndices[offset + 2] = triangle.v2; + } + + indices[subMeshIndex] = subMeshIndices; + } + else + { + // This mesh doesn't have any triangles left + indices[subMeshIndex] = new int[0]; + } + } + + Mesh newMesh = new Mesh(vertices, indices); + + if (vertNormals != null) + { + newMesh.Normals = vertNormals.Data; + } + if (vertTangents != null) + { + newMesh.Tangents = vertTangents.Data; + } + if (vertColors != null) + { + newMesh.Colors = vertColors.Data; + } + if (vertBoneWeights != null) + { + newMesh.BoneWeights = vertBoneWeights.Data; + } + + if (vertUV2D != null) + { + for (int i = 0; i < Mesh.UVChannelCount; i++) + { + if (vertUV2D[i] != null) + { + var uvSet = vertUV2D[i].Data; + newMesh.SetUVs(i, uvSet); + } + } + } + + if (vertUV3D != null) + { + for (int i = 0; i < Mesh.UVChannelCount; i++) + { + if (vertUV3D[i] != null) + { + var uvSet = vertUV3D[i].Data; + newMesh.SetUVs(i, uvSet); + } + } + } + + if (vertUV4D != null) + { + for (int i = 0; i < Mesh.UVChannelCount; i++) + { + if (vertUV4D[i] != null) + { + var uvSet = vertUV4D[i].Data; + newMesh.SetUVs(i, uvSet); + } + } + } + + return newMesh; + } + #endregion + #endregion + } +} diff --git a/LightlessSync/ThirdParty/MeshDecimator/BoneWeight.cs b/LightlessSync/ThirdParty/MeshDecimator/BoneWeight.cs new file mode 100644 index 0000000..6501468 --- /dev/null +++ b/LightlessSync/ThirdParty/MeshDecimator/BoneWeight.cs @@ -0,0 +1,249 @@ +#region License +/* +MIT License + +Copyright(c) 2017-2018 Mattias Edlund + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +#endregion + +using System; +using MeshDecimator.Math; + +namespace MeshDecimator +{ + /// + /// A bone weight. + /// + public struct BoneWeight : IEquatable + { + #region Fields + /// + /// The first bone index. + /// + public int boneIndex0; + /// + /// The second bone index. + /// + public int boneIndex1; + /// + /// The third bone index. + /// + public int boneIndex2; + /// + /// The fourth bone index. + /// + public int boneIndex3; + + /// + /// The first bone weight. + /// + public float boneWeight0; + /// + /// The second bone weight. + /// + public float boneWeight1; + /// + /// The third bone weight. + /// + public float boneWeight2; + /// + /// The fourth bone weight. + /// + public float boneWeight3; + #endregion + + #region Constructor + /// + /// Creates a new bone weight. + /// + /// The first bone index. + /// The second bone index. + /// The third bone index. + /// The fourth bone index. + /// The first bone weight. + /// The second bone weight. + /// The third bone weight. + /// The fourth bone weight. + public BoneWeight(int boneIndex0, int boneIndex1, int boneIndex2, int boneIndex3, float boneWeight0, float boneWeight1, float boneWeight2, float boneWeight3) + { + this.boneIndex0 = boneIndex0; + this.boneIndex1 = boneIndex1; + this.boneIndex2 = boneIndex2; + this.boneIndex3 = boneIndex3; + + this.boneWeight0 = boneWeight0; + this.boneWeight1 = boneWeight1; + this.boneWeight2 = boneWeight2; + this.boneWeight3 = boneWeight3; + } + #endregion + + #region Operators + /// + /// Returns if two bone weights equals eachother. + /// + /// The left hand side bone weight. + /// The right hand side bone weight. + /// If equals. + public static bool operator ==(BoneWeight lhs, BoneWeight rhs) + { + return (lhs.boneIndex0 == rhs.boneIndex0 && lhs.boneIndex1 == rhs.boneIndex1 && lhs.boneIndex2 == rhs.boneIndex2 && lhs.boneIndex3 == rhs.boneIndex3 && + new Vector4(lhs.boneWeight0, lhs.boneWeight1, lhs.boneWeight2, lhs.boneWeight3) == new Vector4(rhs.boneWeight0, rhs.boneWeight1, rhs.boneWeight2, rhs.boneWeight3)); + } + + /// + /// Returns if two bone weights don't equal eachother. + /// + /// The left hand side bone weight. + /// The right hand side bone weight. + /// If not equals. + public static bool operator !=(BoneWeight lhs, BoneWeight rhs) + { + return !(lhs == rhs); + } + #endregion + + #region Private Methods + private void MergeBoneWeight(int boneIndex, float weight) + { + if (boneIndex == boneIndex0) + { + boneWeight0 = (boneWeight0 + weight) * 0.5f; + } + else if (boneIndex == boneIndex1) + { + boneWeight1 = (boneWeight1 + weight) * 0.5f; + } + else if (boneIndex == boneIndex2) + { + boneWeight2 = (boneWeight2 + weight) * 0.5f; + } + else if (boneIndex == boneIndex3) + { + boneWeight3 = (boneWeight3 + weight) * 0.5f; + } + else if(boneWeight0 == 0f) + { + boneIndex0 = boneIndex; + boneWeight0 = weight; + } + else if (boneWeight1 == 0f) + { + boneIndex1 = boneIndex; + boneWeight1 = weight; + } + else if (boneWeight2 == 0f) + { + boneIndex2 = boneIndex; + boneWeight2 = weight; + } + else if (boneWeight3 == 0f) + { + boneIndex3 = boneIndex; + boneWeight3 = weight; + } + Normalize(); + } + + private void Normalize() + { + float mag = (float)System.Math.Sqrt(boneWeight0 * boneWeight0 + boneWeight1 * boneWeight1 + boneWeight2 * boneWeight2 + boneWeight3 * boneWeight3); + if (mag > float.Epsilon) + { + boneWeight0 /= mag; + boneWeight1 /= mag; + boneWeight2 /= mag; + boneWeight3 /= mag; + } + else + { + boneWeight0 = boneWeight1 = boneWeight2 = boneWeight3 = 0f; + } + } + #endregion + + #region Public Methods + #region Object + /// + /// Returns a hash code for this vector. + /// + /// The hash code. + public override int GetHashCode() + { + return boneIndex0.GetHashCode() ^ boneIndex1.GetHashCode() << 2 ^ boneIndex2.GetHashCode() >> 2 ^ boneIndex3.GetHashCode() >> + 1 ^ boneWeight0.GetHashCode() << 5 ^ boneWeight1.GetHashCode() << 4 ^ boneWeight2.GetHashCode() >> 4 ^ boneWeight3.GetHashCode() >> 3; + } + + /// + /// Returns if this bone weight is equal to another object. + /// + /// The other object to compare to. + /// If equals. + public override bool Equals(object obj) + { + if (!(obj is BoneWeight)) + { + return false; + } + BoneWeight other = (BoneWeight)obj; + return (boneIndex0 == other.boneIndex0 && boneIndex1 == other.boneIndex1 && boneIndex2 == other.boneIndex2 && boneIndex3 == other.boneIndex3 && + boneWeight0 == other.boneWeight0 && boneWeight1 == other.boneWeight1 && boneWeight2 == other.boneWeight2 && boneWeight3 == other.boneWeight3); + } + + /// + /// Returns if this bone weight is equal to another one. + /// + /// The other bone weight to compare to. + /// If equals. + public bool Equals(BoneWeight other) + { + return (boneIndex0 == other.boneIndex0 && boneIndex1 == other.boneIndex1 && boneIndex2 == other.boneIndex2 && boneIndex3 == other.boneIndex3 && + boneWeight0 == other.boneWeight0 && boneWeight1 == other.boneWeight1 && boneWeight2 == other.boneWeight2 && boneWeight3 == other.boneWeight3); + } + + /// + /// Returns a nicely formatted string for this bone weight. + /// + /// The string. + public override string ToString() + { + return string.Format("({0}:{4:F1}, {1}:{5:F1}, {2}:{6:F1}, {3}:{7:F1})", + boneIndex0, boneIndex1, boneIndex2, boneIndex3, boneWeight0, boneWeight1, boneWeight2, boneWeight3); + } + #endregion + + #region Static + /// + /// Merges two bone weights and stores the merged result in the first parameter. + /// + /// The first bone weight, also stores result. + /// The second bone weight. + public static void Merge(ref BoneWeight a, ref BoneWeight b) + { + if (b.boneWeight0 > 0f) a.MergeBoneWeight(b.boneIndex0, b.boneWeight0); + if (b.boneWeight1 > 0f) a.MergeBoneWeight(b.boneIndex1, b.boneWeight1); + if (b.boneWeight2 > 0f) a.MergeBoneWeight(b.boneIndex2, b.boneWeight2); + if (b.boneWeight3 > 0f) a.MergeBoneWeight(b.boneIndex3, b.boneWeight3); + } + #endregion + #endregion + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Collections/ResizableArray.cs b/LightlessSync/ThirdParty/MeshDecimator/Collections/ResizableArray.cs new file mode 100644 index 0000000..2c69814 --- /dev/null +++ b/LightlessSync/ThirdParty/MeshDecimator/Collections/ResizableArray.cs @@ -0,0 +1,179 @@ +#region License +/* +MIT License + +Copyright(c) 2017-2018 Mattias Edlund + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +#endregion + +using System; + +namespace MeshDecimator.Collections +{ + /// + /// A resizable array. + /// + /// The item type. + internal sealed class ResizableArray + { + #region Fields + private T[] items = null; + private int length = 0; + + private static T[] emptyArr = new T[0]; + #endregion + + #region Properties + /// + /// Gets the length of this array. + /// + public int Length + { + get { return length; } + } + + /// + /// Gets the internal data buffer for this array. + /// + public T[] Data + { + get { return items; } + } + + /// + /// Gets or sets the element value at a specific index. + /// + /// The element index. + /// The element value. + public T this[int index] + { + get { return items[index]; } + set { items[index] = value; } + } + #endregion + + #region Constructor + /// + /// Creates a new resizable array. + /// + /// The initial array capacity. + public ResizableArray(int capacity) + : this(capacity, 0) + { + + } + + /// + /// Creates a new resizable array. + /// + /// The initial array capacity. + /// The initial length of the array. + public ResizableArray(int capacity, int length) + { + if (capacity < 0) + throw new ArgumentOutOfRangeException("capacity"); + else if (length < 0 || length > capacity) + throw new ArgumentOutOfRangeException("length"); + + if (capacity > 0) + items = new T[capacity]; + else + items = emptyArr; + + this.length = length; + } + #endregion + + #region Private Methods + private void IncreaseCapacity(int capacity) + { + T[] newItems = new T[capacity]; + Array.Copy(items, 0, newItems, 0, System.Math.Min(length, capacity)); + items = newItems; + } + #endregion + + #region Public Methods + /// + /// Clears this array. + /// + public void Clear() + { + Array.Clear(items, 0, length); + length = 0; + } + + /// + /// Resizes this array. + /// + /// The new length. + /// If exess memory should be trimmed. + public void Resize(int length, bool trimExess = false) + { + if (length < 0) + throw new ArgumentOutOfRangeException("capacity"); + + if (length > items.Length) + { + IncreaseCapacity(length); + } + else if (length < this.length) + { + //Array.Clear(items, capacity, length - capacity); + } + + this.length = length; + + if (trimExess) + { + TrimExcess(); + } + } + + /// + /// Trims any excess memory for this array. + /// + public void TrimExcess() + { + if (items.Length == length) // Nothing to do + return; + + T[] newItems = new T[length]; + Array.Copy(items, 0, newItems, 0, length); + items = newItems; + } + + /// + /// Adds a new item to the end of this array. + /// + /// The new item. + public void Add(T item) + { + if (length >= items.Length) + { + IncreaseCapacity(items.Length << 1); + } + + items[length++] = item; + } + #endregion + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Collections/UVChannels.cs b/LightlessSync/ThirdParty/MeshDecimator/Collections/UVChannels.cs new file mode 100644 index 0000000..073728a --- /dev/null +++ b/LightlessSync/ThirdParty/MeshDecimator/Collections/UVChannels.cs @@ -0,0 +1,79 @@ +using System; + +namespace MeshDecimator.Collections +{ + /// + /// A collection of UV channels. + /// + /// The UV vector type. + internal sealed class UVChannels + { + #region Fields + private ResizableArray[] channels = null; + private TVec[][] channelsData = null; + #endregion + + #region Properties + /// + /// Gets the channel collection data. + /// + public TVec[][] Data + { + get + { + for (int i = 0; i < Mesh.UVChannelCount; i++) + { + if (channels[i] != null) + { + channelsData[i] = channels[i].Data; + } + else + { + channelsData[i] = null; + } + } + return channelsData; + } + } + + /// + /// Gets or sets a specific channel by index. + /// + /// The channel index. + public ResizableArray this[int index] + { + get { return channels[index]; } + set { channels[index] = value; } + } + #endregion + + #region Constructor + /// + /// Creates a new collection of UV channels. + /// + public UVChannels() + { + channels = new ResizableArray[Mesh.UVChannelCount]; + channelsData = new TVec[Mesh.UVChannelCount][]; + } + #endregion + + #region Public Methods + /// + /// Resizes all channels at once. + /// + /// The new capacity. + /// If exess memory should be trimmed. + public void Resize(int capacity, bool trimExess = false) + { + for (int i = 0; i < Mesh.UVChannelCount; i++) + { + if (channels[i] != null) + { + channels[i].Resize(capacity, trimExess); + } + } + } + #endregion + } +} diff --git a/LightlessSync/ThirdParty/MeshDecimator/LICENSE.md b/LightlessSync/ThirdParty/MeshDecimator/LICENSE.md new file mode 100644 index 0000000..1f1f192 --- /dev/null +++ b/LightlessSync/ThirdParty/MeshDecimator/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017-2018 Mattias Edlund + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/MathHelper.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/MathHelper.cs new file mode 100644 index 0000000..b530d3d --- /dev/null +++ b/LightlessSync/ThirdParty/MeshDecimator/Math/MathHelper.cs @@ -0,0 +1,286 @@ +#region License +/* +MIT License + +Copyright(c) 2017-2018 Mattias Edlund + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +#endregion + +using System; + +namespace MeshDecimator.Math +{ + /// + /// Math helpers. + /// + public static class MathHelper + { + #region Consts + /// + /// The Pi constant. + /// + public const float PI = 3.14159274f; + + /// + /// The Pi constant. + /// + public const double PId = 3.1415926535897932384626433832795; + + /// + /// Degrees to radian constant. + /// + public const float Deg2Rad = PI / 180f; + + /// + /// Degrees to radian constant. + /// + public const double Deg2Radd = PId / 180.0; + + /// + /// Radians to degrees constant. + /// + public const float Rad2Deg = 180f / PI; + + /// + /// Radians to degrees constant. + /// + public const double Rad2Degd = 180.0 / PId; + #endregion + + #region Min + /// + /// Returns the minimum of two values. + /// + /// The first value. + /// The second value. + /// The minimum value. + public static int Min(int val1, int val2) + { + return (val1 < val2 ? val1 : val2); + } + + /// + /// Returns the minimum of three values. + /// + /// The first value. + /// The second value. + /// The third value. + /// The minimum value. + public static int Min(int val1, int val2, int val3) + { + return (val1 < val2 ? (val1 < val3 ? val1 : val3) : (val2 < val3 ? val2 : val3)); + } + + /// + /// Returns the minimum of two values. + /// + /// The first value. + /// The second value. + /// The minimum value. + public static float Min(float val1, float val2) + { + return (val1 < val2 ? val1 : val2); + } + + /// + /// Returns the minimum of three values. + /// + /// The first value. + /// The second value. + /// The third value. + /// The minimum value. + public static float Min(float val1, float val2, float val3) + { + return (val1 < val2 ? (val1 < val3 ? val1 : val3) : (val2 < val3 ? val2 : val3)); + } + + /// + /// Returns the minimum of two values. + /// + /// The first value. + /// The second value. + /// The minimum value. + public static double Min(double val1, double val2) + { + return (val1 < val2 ? val1 : val2); + } + + /// + /// Returns the minimum of three values. + /// + /// The first value. + /// The second value. + /// The third value. + /// The minimum value. + public static double Min(double val1, double val2, double val3) + { + return (val1 < val2 ? (val1 < val3 ? val1 : val3) : (val2 < val3 ? val2 : val3)); + } + #endregion + + #region Max + /// + /// Returns the maximum of two values. + /// + /// The first value. + /// The second value. + /// The maximum value. + public static int Max(int val1, int val2) + { + return (val1 > val2 ? val1 : val2); + } + + /// + /// Returns the maximum of three values. + /// + /// The first value. + /// The second value. + /// The third value. + /// The maximum value. + public static int Max(int val1, int val2, int val3) + { + return (val1 > val2 ? (val1 > val3 ? val1 : val3) : (val2 > val3 ? val2 : val3)); + } + + /// + /// Returns the maximum of two values. + /// + /// The first value. + /// The second value. + /// The maximum value. + public static float Max(float val1, float val2) + { + return (val1 > val2 ? val1 : val2); + } + + /// + /// Returns the maximum of three values. + /// + /// The first value. + /// The second value. + /// The third value. + /// The maximum value. + public static float Max(float val1, float val2, float val3) + { + return (val1 > val2 ? (val1 > val3 ? val1 : val3) : (val2 > val3 ? val2 : val3)); + } + + /// + /// Returns the maximum of two values. + /// + /// The first value. + /// The second value. + /// The maximum value. + public static double Max(double val1, double val2) + { + return (val1 > val2 ? val1 : val2); + } + + /// + /// Returns the maximum of three values. + /// + /// The first value. + /// The second value. + /// The third value. + /// The maximum value. + public static double Max(double val1, double val2, double val3) + { + return (val1 > val2 ? (val1 > val3 ? val1 : val3) : (val2 > val3 ? val2 : val3)); + } + #endregion + + #region Clamping + /// + /// Clamps a value between a minimum and a maximum value. + /// + /// The value to clamp. + /// The minimum value. + /// The maximum value. + /// The clamped value. + public static float Clamp(float value, float min, float max) + { + return (value >= min ? (value <= max ? value : max) : min); + } + + /// + /// Clamps a value between a minimum and a maximum value. + /// + /// The value to clamp. + /// The minimum value. + /// The maximum value. + /// The clamped value. + public static double Clamp(double value, double min, double max) + { + return (value >= min ? (value <= max ? value : max) : min); + } + + /// + /// Clamps the value between 0 and 1. + /// + /// The value to clamp. + /// The clamped value. + public static float Clamp01(float value) + { + return (value > 0f ? (value < 1f ? value : 1f) : 0f); + } + + /// + /// Clamps the value between 0 and 1. + /// + /// The value to clamp. + /// The clamped value. + public static double Clamp01(double value) + { + return (value > 0.0 ? (value < 1.0 ? value : 1.0) : 0.0); + } + #endregion + + #region Triangle Area + /// + /// Calculates the area of a triangle. + /// + /// The first point. + /// The second point. + /// The third point. + /// The triangle area. + public static float TriangleArea(ref Vector3 p0, ref Vector3 p1, ref Vector3 p2) + { + var dx = p1 - p0; + var dy = p2 - p0; + return dx.Magnitude * ((float)System.Math.Sin(Vector3.Angle(ref dx, ref dy) * Deg2Rad) * dy.Magnitude) * 0.5f; + } + + /// + /// Calculates the area of a triangle. + /// + /// The first point. + /// The second point. + /// The third point. + /// The triangle area. + public static double TriangleArea(ref Vector3d p0, ref Vector3d p1, ref Vector3d p2) + { + var dx = p1 - p0; + var dy = p2 - p0; + return dx.Magnitude * (System.Math.Sin(Vector3d.Angle(ref dx, ref dy) * Deg2Radd) * dy.Magnitude) * 0.5f; + } + #endregion + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/SymmetricMatrix.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/SymmetricMatrix.cs new file mode 100644 index 0000000..3daa4e7 --- /dev/null +++ b/LightlessSync/ThirdParty/MeshDecimator/Math/SymmetricMatrix.cs @@ -0,0 +1,303 @@ +#region License +/* +MIT License + +Copyright(c) 2017-2018 Mattias Edlund + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +#endregion + +using System; + +namespace MeshDecimator.Math +{ + /// + /// A symmetric matrix. + /// + public struct SymmetricMatrix + { + #region Fields + /// + /// The m11 component. + /// + public double m0; + /// + /// The m12 component. + /// + public double m1; + /// + /// The m13 component. + /// + public double m2; + /// + /// The m14 component. + /// + public double m3; + /// + /// The m22 component. + /// + public double m4; + /// + /// The m23 component. + /// + public double m5; + /// + /// The m24 component. + /// + public double m6; + /// + /// The m33 component. + /// + public double m7; + /// + /// The m34 component. + /// + public double m8; + /// + /// The m44 component. + /// + public double m9; + #endregion + + #region Properties + /// + /// Gets the component value with a specific index. + /// + /// The component index. + /// The value. + public double this[int index] + { + get + { + switch (index) + { + case 0: + return m0; + case 1: + return m1; + case 2: + return m2; + case 3: + return m3; + case 4: + return m4; + case 5: + return m5; + case 6: + return m6; + case 7: + return m7; + case 8: + return m8; + case 9: + return m9; + default: + throw new IndexOutOfRangeException(); + } + } + } + #endregion + + #region Constructor + /// + /// Creates a symmetric matrix with a value in each component. + /// + /// The component value. + public SymmetricMatrix(double c) + { + this.m0 = c; + this.m1 = c; + this.m2 = c; + this.m3 = c; + this.m4 = c; + this.m5 = c; + this.m6 = c; + this.m7 = c; + this.m8 = c; + this.m9 = c; + } + + /// + /// Creates a symmetric matrix. + /// + /// The m11 component. + /// The m12 component. + /// The m13 component. + /// The m14 component. + /// The m22 component. + /// The m23 component. + /// The m24 component. + /// The m33 component. + /// The m34 component. + /// The m44 component. + public SymmetricMatrix(double m0, double m1, double m2, double m3, + double m4, double m5, double m6, double m7, double m8, double m9) + { + this.m0 = m0; + this.m1 = m1; + this.m2 = m2; + this.m3 = m3; + this.m4 = m4; + this.m5 = m5; + this.m6 = m6; + this.m7 = m7; + this.m8 = m8; + this.m9 = m9; + } + + /// + /// Creates a symmetric matrix from a plane. + /// + /// The plane x-component. + /// The plane y-component + /// The plane z-component + /// The plane w-component + public SymmetricMatrix(double a, double b, double c, double d) + { + this.m0 = a * a; + this.m1 = a * b; + this.m2 = a * c; + this.m3 = a * d; + + this.m4 = b * b; + this.m5 = b * c; + this.m6 = b * d; + + this.m7 = c * c; + this.m8 = c * d; + + this.m9 = d * d; + } + #endregion + + #region Operators + /// + /// Adds two matrixes together. + /// + /// The left hand side. + /// The right hand side. + /// The resulting matrix. + public static SymmetricMatrix operator +(SymmetricMatrix a, SymmetricMatrix b) + { + return new SymmetricMatrix( + a.m0 + b.m0, a.m1 + b.m1, a.m2 + b.m2, a.m3 + b.m3, + a.m4 + b.m4, a.m5 + b.m5, a.m6 + b.m6, + a.m7 + b.m7, a.m8 + b.m8, + a.m9 + b.m9 + ); + } + #endregion + + #region Internal Methods + /// + /// Determinant(0, 1, 2, 1, 4, 5, 2, 5, 7) + /// + /// + internal double Determinant1() + { + double det = + m0 * m4 * m7 + + m2 * m1 * m5 + + m1 * m5 * m2 - + m2 * m4 * m2 - + m0 * m5 * m5 - + m1 * m1 * m7; + return det; + } + + /// + /// Determinant(1, 2, 3, 4, 5, 6, 5, 7, 8) + /// + /// + internal double Determinant2() + { + double det = + m1 * m5 * m8 + + m3 * m4 * m7 + + m2 * m6 * m5 - + m3 * m5 * m5 - + m1 * m6 * m7 - + m2 * m4 * m8; + return det; + } + + /// + /// Determinant(0, 2, 3, 1, 5, 6, 2, 7, 8) + /// + /// + internal double Determinant3() + { + double det = + m0 * m5 * m8 + + m3 * m1 * m7 + + m2 * m6 * m2 - + m3 * m5 * m2 - + m0 * m6 * m7 - + m2 * m1 * m8; + return det; + } + + /// + /// Determinant(0, 1, 3, 1, 4, 6, 2, 5, 8) + /// + /// + internal double Determinant4() + { + double det = + m0 * m4 * m8 + + m3 * m1 * m5 + + m1 * m6 * m2 - + m3 * m4 * m2 - + m0 * m6 * m5 - + m1 * m1 * m8; + return det; + } + #endregion + + #region Public Methods + /// + /// Computes the determinant of this matrix. + /// + /// The a11 index. + /// The a12 index. + /// The a13 index. + /// The a21 index. + /// The a22 index. + /// The a23 index. + /// The a31 index. + /// The a32 index. + /// The a33 index. + /// The determinant value. + public double Determinant(int a11, int a12, int a13, + int a21, int a22, int a23, + int a31, int a32, int a33) + { + double det = + this[a11] * this[a22] * this[a33] + + this[a13] * this[a21] * this[a32] + + this[a12] * this[a23] * this[a31] - + this[a13] * this[a22] * this[a31] - + this[a11] * this[a23] * this[a32] - + this[a12] * this[a21] * this[a33]; + return det; + } + #endregion + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector2.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector2.cs new file mode 100644 index 0000000..68f06f4 --- /dev/null +++ b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector2.cs @@ -0,0 +1,425 @@ +#region License +/* +MIT License + +Copyright(c) 2017-2018 Mattias Edlund + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +#endregion + +using System; +using System.Globalization; + +namespace MeshDecimator.Math +{ + /// + /// A single precision 2D vector. + /// + public struct Vector2 : IEquatable + { + #region Static Read-Only + /// + /// The zero vector. + /// + public static readonly Vector2 zero = new Vector2(0, 0); + #endregion + + #region Consts + /// + /// The vector epsilon. + /// + public const float Epsilon = 9.99999944E-11f; + #endregion + + #region Fields + /// + /// The x component. + /// + public float x; + /// + /// The y component. + /// + public float y; + #endregion + + #region Properties + /// + /// Gets the magnitude of this vector. + /// + public float Magnitude + { + get { return (float)System.Math.Sqrt(x * x + y * y); } + } + + /// + /// Gets the squared magnitude of this vector. + /// + public float MagnitudeSqr + { + get { return (x * x + y * y); } + } + + /// + /// Gets a normalized vector from this vector. + /// + public Vector2 Normalized + { + get + { + Vector2 result; + Normalize(ref this, out result); + return result; + } + } + + /// + /// Gets or sets a specific component by index in this vector. + /// + /// The component index. + public float this[int index] + { + get + { + switch (index) + { + case 0: + return x; + case 1: + return y; + default: + throw new IndexOutOfRangeException("Invalid Vector2 index!"); + } + } + set + { + switch (index) + { + case 0: + x = value; + break; + case 1: + y = value; + break; + default: + throw new IndexOutOfRangeException("Invalid Vector2 index!"); + } + } + } + #endregion + + #region Constructor + /// + /// Creates a new vector with one value for all components. + /// + /// The value. + public Vector2(float value) + { + this.x = value; + this.y = value; + } + + /// + /// Creates a new vector. + /// + /// The x value. + /// The y value. + public Vector2(float x, float y) + { + this.x = x; + this.y = y; + } + #endregion + + #region Operators + /// + /// Adds two vectors. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static Vector2 operator +(Vector2 a, Vector2 b) + { + return new Vector2(a.x + b.x, a.y + b.y); + } + + /// + /// Subtracts two vectors. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static Vector2 operator -(Vector2 a, Vector2 b) + { + return new Vector2(a.x - b.x, a.y - b.y); + } + + /// + /// Scales the vector uniformly. + /// + /// The vector. + /// The scaling value. + /// The resulting vector. + public static Vector2 operator *(Vector2 a, float d) + { + return new Vector2(a.x * d, a.y * d); + } + + /// + /// Scales the vector uniformly. + /// + /// The scaling value. + /// The vector. + /// The resulting vector. + public static Vector2 operator *(float d, Vector2 a) + { + return new Vector2(a.x * d, a.y * d); + } + + /// + /// Divides the vector with a float. + /// + /// The vector. + /// The dividing float value. + /// The resulting vector. + public static Vector2 operator /(Vector2 a, float d) + { + return new Vector2(a.x / d, a.y / d); + } + + /// + /// Subtracts the vector from a zero vector. + /// + /// The vector. + /// The resulting vector. + public static Vector2 operator -(Vector2 a) + { + return new Vector2(-a.x, -a.y); + } + + /// + /// Returns if two vectors equals eachother. + /// + /// The left hand side vector. + /// The right hand side vector. + /// If equals. + public static bool operator ==(Vector2 lhs, Vector2 rhs) + { + return (lhs - rhs).MagnitudeSqr < Epsilon; + } + + /// + /// Returns if two vectors don't equal eachother. + /// + /// The left hand side vector. + /// The right hand side vector. + /// If not equals. + public static bool operator !=(Vector2 lhs, Vector2 rhs) + { + return (lhs - rhs).MagnitudeSqr >= Epsilon; + } + + /// + /// Explicitly converts from a double-precision vector into a single-precision vector. + /// + /// The double-precision vector. + public static explicit operator Vector2(Vector2d v) + { + return new Vector2((float)v.x, (float)v.y); + } + + /// + /// Implicitly converts from an integer vector into a single-precision vector. + /// + /// The integer vector. + public static implicit operator Vector2(Vector2i v) + { + return new Vector2(v.x, v.y); + } + #endregion + + #region Public Methods + #region Instance + /// + /// Set x and y components of an existing vector. + /// + /// The x value. + /// The y value. + public void Set(float x, float y) + { + this.x = x; + this.y = y; + } + + /// + /// Multiplies with another vector component-wise. + /// + /// The vector to multiply with. + public void Scale(ref Vector2 scale) + { + x *= scale.x; + y *= scale.y; + } + + /// + /// Normalizes this vector. + /// + public void Normalize() + { + float mag = this.Magnitude; + if (mag > Epsilon) + { + x /= mag; + y /= mag; + } + else + { + x = y = 0; + } + } + + /// + /// Clamps this vector between a specific range. + /// + /// The minimum component value. + /// The maximum component value. + public void Clamp(float min, float max) + { + if (x < min) x = min; + else if (x > max) x = max; + + if (y < min) y = min; + else if (y > max) y = max; + } + #endregion + + #region Object + /// + /// Returns a hash code for this vector. + /// + /// The hash code. + public override int GetHashCode() + { + return x.GetHashCode() ^ y.GetHashCode() << 2; + } + + /// + /// Returns if this vector is equal to another one. + /// + /// The other vector to compare to. + /// If equals. + public override bool Equals(object other) + { + if (!(other is Vector2)) + { + return false; + } + Vector2 vector = (Vector2)other; + return (x == vector.x && y == vector.y); + } + + /// + /// Returns if this vector is equal to another one. + /// + /// The other vector to compare to. + /// If equals. + public bool Equals(Vector2 other) + { + return (x == other.x && y == other.y); + } + + /// + /// Returns a nicely formatted string for this vector. + /// + /// The string. + public override string ToString() + { + return string.Format("({0}, {1})", + x.ToString("F1", CultureInfo.InvariantCulture), + y.ToString("F1", CultureInfo.InvariantCulture)); + } + + /// + /// Returns a nicely formatted string for this vector. + /// + /// The float format. + /// The string. + public string ToString(string format) + { + return string.Format("({0}, {1})", + x.ToString(format, CultureInfo.InvariantCulture), + y.ToString(format, CultureInfo.InvariantCulture)); + } + #endregion + + #region Static + /// + /// Dot Product of two vectors. + /// + /// The left hand side vector. + /// The right hand side vector. + public static float Dot(ref Vector2 lhs, ref Vector2 rhs) + { + return lhs.x * rhs.x + lhs.y * rhs.y; + } + + /// + /// Performs a linear interpolation between two vectors. + /// + /// The vector to interpolate from. + /// The vector to interpolate to. + /// The time fraction. + /// The resulting vector. + public static void Lerp(ref Vector2 a, ref Vector2 b, float t, out Vector2 result) + { + result = new Vector2(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t); + } + + /// + /// Multiplies two vectors component-wise. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static void Scale(ref Vector2 a, ref Vector2 b, out Vector2 result) + { + result = new Vector2(a.x * b.x, a.y * b.y); + } + + /// + /// Normalizes a vector. + /// + /// The vector to normalize. + /// The resulting normalized vector. + public static void Normalize(ref Vector2 value, out Vector2 result) + { + float mag = value.Magnitude; + if (mag > Epsilon) + { + result = new Vector2(value.x / mag, value.y / mag); + } + else + { + result = Vector2.zero; + } + } + #endregion + #endregion + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector2d.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector2d.cs new file mode 100644 index 0000000..72f62aa --- /dev/null +++ b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector2d.cs @@ -0,0 +1,425 @@ +#region License +/* +MIT License + +Copyright(c) 2017-2018 Mattias Edlund + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +#endregion + +using System; +using System.Globalization; + +namespace MeshDecimator.Math +{ + /// + /// A double precision 2D vector. + /// + public struct Vector2d : IEquatable + { + #region Static Read-Only + /// + /// The zero vector. + /// + public static readonly Vector2d zero = new Vector2d(0, 0); + #endregion + + #region Consts + /// + /// The vector epsilon. + /// + public const double Epsilon = double.Epsilon; + #endregion + + #region Fields + /// + /// The x component. + /// + public double x; + /// + /// The y component. + /// + public double y; + #endregion + + #region Properties + /// + /// Gets the magnitude of this vector. + /// + public double Magnitude + { + get { return System.Math.Sqrt(x * x + y * y); } + } + + /// + /// Gets the squared magnitude of this vector. + /// + public double MagnitudeSqr + { + get { return (x * x + y * y); } + } + + /// + /// Gets a normalized vector from this vector. + /// + public Vector2d Normalized + { + get + { + Vector2d result; + Normalize(ref this, out result); + return result; + } + } + + /// + /// Gets or sets a specific component by index in this vector. + /// + /// The component index. + public double this[int index] + { + get + { + switch (index) + { + case 0: + return x; + case 1: + return y; + default: + throw new IndexOutOfRangeException("Invalid Vector2d index!"); + } + } + set + { + switch (index) + { + case 0: + x = value; + break; + case 1: + y = value; + break; + default: + throw new IndexOutOfRangeException("Invalid Vector2d index!"); + } + } + } + #endregion + + #region Constructor + /// + /// Creates a new vector with one value for all components. + /// + /// The value. + public Vector2d(double value) + { + this.x = value; + this.y = value; + } + + /// + /// Creates a new vector. + /// + /// The x value. + /// The y value. + public Vector2d(double x, double y) + { + this.x = x; + this.y = y; + } + #endregion + + #region Operators + /// + /// Adds two vectors. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static Vector2d operator +(Vector2d a, Vector2d b) + { + return new Vector2d(a.x + b.x, a.y + b.y); + } + + /// + /// Subtracts two vectors. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static Vector2d operator -(Vector2d a, Vector2d b) + { + return new Vector2d(a.x - b.x, a.y - b.y); + } + + /// + /// Scales the vector uniformly. + /// + /// The vector. + /// The scaling value. + /// The resulting vector. + public static Vector2d operator *(Vector2d a, double d) + { + return new Vector2d(a.x * d, a.y * d); + } + + /// + /// Scales the vector uniformly. + /// + /// The scaling value. + /// The vector. + /// The resulting vector. + public static Vector2d operator *(double d, Vector2d a) + { + return new Vector2d(a.x * d, a.y * d); + } + + /// + /// Divides the vector with a float. + /// + /// The vector. + /// The dividing float value. + /// The resulting vector. + public static Vector2d operator /(Vector2d a, double d) + { + return new Vector2d(a.x / d, a.y / d); + } + + /// + /// Subtracts the vector from a zero vector. + /// + /// The vector. + /// The resulting vector. + public static Vector2d operator -(Vector2d a) + { + return new Vector2d(-a.x, -a.y); + } + + /// + /// Returns if two vectors equals eachother. + /// + /// The left hand side vector. + /// The right hand side vector. + /// If equals. + public static bool operator ==(Vector2d lhs, Vector2d rhs) + { + return (lhs - rhs).MagnitudeSqr < Epsilon; + } + + /// + /// Returns if two vectors don't equal eachother. + /// + /// The left hand side vector. + /// The right hand side vector. + /// If not equals. + public static bool operator !=(Vector2d lhs, Vector2d rhs) + { + return (lhs - rhs).MagnitudeSqr >= Epsilon; + } + + /// + /// Implicitly converts from a single-precision vector into a double-precision vector. + /// + /// The single-precision vector. + public static implicit operator Vector2d(Vector2 v) + { + return new Vector2d(v.x, v.y); + } + + /// + /// Implicitly converts from an integer vector into a double-precision vector. + /// + /// The integer vector. + public static implicit operator Vector2d(Vector2i v) + { + return new Vector2d(v.x, v.y); + } + #endregion + + #region Public Methods + #region Instance + /// + /// Set x and y components of an existing vector. + /// + /// The x value. + /// The y value. + public void Set(double x, double y) + { + this.x = x; + this.y = y; + } + + /// + /// Multiplies with another vector component-wise. + /// + /// The vector to multiply with. + public void Scale(ref Vector2d scale) + { + x *= scale.x; + y *= scale.y; + } + + /// + /// Normalizes this vector. + /// + public void Normalize() + { + double mag = this.Magnitude; + if (mag > Epsilon) + { + x /= mag; + y /= mag; + } + else + { + x = y = 0; + } + } + + /// + /// Clamps this vector between a specific range. + /// + /// The minimum component value. + /// The maximum component value. + public void Clamp(double min, double max) + { + if (x < min) x = min; + else if (x > max) x = max; + + if (y < min) y = min; + else if (y > max) y = max; + } + #endregion + + #region Object + /// + /// Returns a hash code for this vector. + /// + /// The hash code. + public override int GetHashCode() + { + return x.GetHashCode() ^ y.GetHashCode() << 2; + } + + /// + /// Returns if this vector is equal to another one. + /// + /// The other vector to compare to. + /// If equals. + public override bool Equals(object other) + { + if (!(other is Vector2d)) + { + return false; + } + Vector2d vector = (Vector2d)other; + return (x == vector.x && y == vector.y); + } + + /// + /// Returns if this vector is equal to another one. + /// + /// The other vector to compare to. + /// If equals. + public bool Equals(Vector2d other) + { + return (x == other.x && y == other.y); + } + + /// + /// Returns a nicely formatted string for this vector. + /// + /// The string. + public override string ToString() + { + return string.Format("({0}, {1})", + x.ToString("F1", CultureInfo.InvariantCulture), + y.ToString("F1", CultureInfo.InvariantCulture)); + } + + /// + /// Returns a nicely formatted string for this vector. + /// + /// The float format. + /// The string. + public string ToString(string format) + { + return string.Format("({0}, {1})", + x.ToString(format, CultureInfo.InvariantCulture), + y.ToString(format, CultureInfo.InvariantCulture)); + } + #endregion + + #region Static + /// + /// Dot Product of two vectors. + /// + /// The left hand side vector. + /// The right hand side vector. + public static double Dot(ref Vector2d lhs, ref Vector2d rhs) + { + return lhs.x * rhs.x + lhs.y * rhs.y; + } + + /// + /// Performs a linear interpolation between two vectors. + /// + /// The vector to interpolate from. + /// The vector to interpolate to. + /// The time fraction. + /// The resulting vector. + public static void Lerp(ref Vector2d a, ref Vector2d b, double t, out Vector2d result) + { + result = new Vector2d(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t); + } + + /// + /// Multiplies two vectors component-wise. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static void Scale(ref Vector2d a, ref Vector2d b, out Vector2d result) + { + result = new Vector2d(a.x * b.x, a.y * b.y); + } + + /// + /// Normalizes a vector. + /// + /// The vector to normalize. + /// The resulting normalized vector. + public static void Normalize(ref Vector2d value, out Vector2d result) + { + double mag = value.Magnitude; + if (mag > Epsilon) + { + result = new Vector2d(value.x / mag, value.y / mag); + } + else + { + result = Vector2d.zero; + } + } + #endregion + #endregion + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector2i.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector2i.cs new file mode 100644 index 0000000..20b808b --- /dev/null +++ b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector2i.cs @@ -0,0 +1,348 @@ +#region License +/* +MIT License + +Copyright(c) 2017-2018 Mattias Edlund + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +#endregion + +using System; +using System.Globalization; + +namespace MeshDecimator.Math +{ + /// + /// A 2D integer vector. + /// + public struct Vector2i : IEquatable + { + #region Static Read-Only + /// + /// The zero vector. + /// + public static readonly Vector2i zero = new Vector2i(0, 0); + #endregion + + #region Fields + /// + /// The x component. + /// + public int x; + /// + /// The y component. + /// + public int y; + #endregion + + #region Properties + /// + /// Gets the magnitude of this vector. + /// + public int Magnitude + { + get { return (int)System.Math.Sqrt(x * x + y * y); } + } + + /// + /// Gets the squared magnitude of this vector. + /// + public int MagnitudeSqr + { + get { return (x * x + y * y); } + } + + /// + /// Gets or sets a specific component by index in this vector. + /// + /// The component index. + public int this[int index] + { + get + { + switch (index) + { + case 0: + return x; + case 1: + return y; + default: + throw new IndexOutOfRangeException("Invalid Vector2i index!"); + } + } + set + { + switch (index) + { + case 0: + x = value; + break; + case 1: + y = value; + break; + default: + throw new IndexOutOfRangeException("Invalid Vector2i index!"); + } + } + } + #endregion + + #region Constructor + /// + /// Creates a new vector with one value for all components. + /// + /// The value. + public Vector2i(int value) + { + this.x = value; + this.y = value; + } + + /// + /// Creates a new vector. + /// + /// The x value. + /// The y value. + public Vector2i(int x, int y) + { + this.x = x; + this.y = y; + } + #endregion + + #region Operators + /// + /// Adds two vectors. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static Vector2i operator +(Vector2i a, Vector2i b) + { + return new Vector2i(a.x + b.x, a.y + b.y); + } + + /// + /// Subtracts two vectors. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static Vector2i operator -(Vector2i a, Vector2i b) + { + return new Vector2i(a.x - b.x, a.y - b.y); + } + + /// + /// Scales the vector uniformly. + /// + /// The vector. + /// The scaling value. + /// The resulting vector. + public static Vector2i operator *(Vector2i a, int d) + { + return new Vector2i(a.x * d, a.y * d); + } + + /// + /// Scales the vector uniformly. + /// + /// The scaling value. + /// The vector. + /// The resulting vector. + public static Vector2i operator *(int d, Vector2i a) + { + return new Vector2i(a.x * d, a.y * d); + } + + /// + /// Divides the vector with a float. + /// + /// The vector. + /// The dividing float value. + /// The resulting vector. + public static Vector2i operator /(Vector2i a, int d) + { + return new Vector2i(a.x / d, a.y / d); + } + + /// + /// Subtracts the vector from a zero vector. + /// + /// The vector. + /// The resulting vector. + public static Vector2i operator -(Vector2i a) + { + return new Vector2i(-a.x, -a.y); + } + + /// + /// Returns if two vectors equals eachother. + /// + /// The left hand side vector. + /// The right hand side vector. + /// If equals. + public static bool operator ==(Vector2i lhs, Vector2i rhs) + { + return (lhs.x == rhs.x && lhs.y == rhs.y); + } + + /// + /// Returns if two vectors don't equal eachother. + /// + /// The left hand side vector. + /// The right hand side vector. + /// If not equals. + public static bool operator !=(Vector2i lhs, Vector2i rhs) + { + return (lhs.x != rhs.x || lhs.y != rhs.y); + } + + /// + /// Explicitly converts from a single-precision vector into an integer vector. + /// + /// The single-precision vector. + public static explicit operator Vector2i(Vector2 v) + { + return new Vector2i((int)v.x, (int)v.y); + } + + /// + /// Explicitly converts from a double-precision vector into an integer vector. + /// + /// The double-precision vector. + public static explicit operator Vector2i(Vector2d v) + { + return new Vector2i((int)v.x, (int)v.y); + } + #endregion + + #region Public Methods + #region Instance + /// + /// Set x and y components of an existing vector. + /// + /// The x value. + /// The y value. + public void Set(int x, int y) + { + this.x = x; + this.y = y; + } + + /// + /// Multiplies with another vector component-wise. + /// + /// The vector to multiply with. + public void Scale(ref Vector2i scale) + { + x *= scale.x; + y *= scale.y; + } + + /// + /// Clamps this vector between a specific range. + /// + /// The minimum component value. + /// The maximum component value. + public void Clamp(int min, int max) + { + if (x < min) x = min; + else if (x > max) x = max; + + if (y < min) y = min; + else if (y > max) y = max; + } + #endregion + + #region Object + /// + /// Returns a hash code for this vector. + /// + /// The hash code. + public override int GetHashCode() + { + return x.GetHashCode() ^ y.GetHashCode() << 2; + } + + /// + /// Returns if this vector is equal to another one. + /// + /// The other vector to compare to. + /// If equals. + public override bool Equals(object other) + { + if (!(other is Vector2i)) + { + return false; + } + Vector2i vector = (Vector2i)other; + return (x == vector.x && y == vector.y); + } + + /// + /// Returns if this vector is equal to another one. + /// + /// The other vector to compare to. + /// If equals. + public bool Equals(Vector2i other) + { + return (x == other.x && y == other.y); + } + + /// + /// Returns a nicely formatted string for this vector. + /// + /// The string. + public override string ToString() + { + return string.Format("({0}, {1})", + x.ToString(CultureInfo.InvariantCulture), + y.ToString(CultureInfo.InvariantCulture)); + } + + /// + /// Returns a nicely formatted string for this vector. + /// + /// The integer format. + /// The string. + public string ToString(string format) + { + return string.Format("({0}, {1})", + x.ToString(format, CultureInfo.InvariantCulture), + y.ToString(format, CultureInfo.InvariantCulture)); + } + #endregion + + #region Static + /// + /// Multiplies two vectors component-wise. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static void Scale(ref Vector2i a, ref Vector2i b, out Vector2i result) + { + result = new Vector2i(a.x * b.x, a.y * b.y); + } + #endregion + #endregion + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector3.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector3.cs new file mode 100644 index 0000000..4c91aa5 --- /dev/null +++ b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector3.cs @@ -0,0 +1,494 @@ +#region License +/* +MIT License + +Copyright(c) 2017-2018 Mattias Edlund + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +#endregion + +using System; +using System.Globalization; + +namespace MeshDecimator.Math +{ + /// + /// A single precision 3D vector. + /// + public struct Vector3 : IEquatable + { + #region Static Read-Only + /// + /// The zero vector. + /// + public static readonly Vector3 zero = new Vector3(0, 0, 0); + #endregion + + #region Consts + /// + /// The vector epsilon. + /// + public const float Epsilon = 9.99999944E-11f; + #endregion + + #region Fields + /// + /// The x component. + /// + public float x; + /// + /// The y component. + /// + public float y; + /// + /// The z component. + /// + public float z; + #endregion + + #region Properties + /// + /// Gets the magnitude of this vector. + /// + public float Magnitude + { + get { return (float)System.Math.Sqrt(x * x + y * y + z * z); } + } + + /// + /// Gets the squared magnitude of this vector. + /// + public float MagnitudeSqr + { + get { return (x * x + y * y + z * z); } + } + + /// + /// Gets a normalized vector from this vector. + /// + public Vector3 Normalized + { + get + { + Vector3 result; + Normalize(ref this, out result); + return result; + } + } + + /// + /// Gets or sets a specific component by index in this vector. + /// + /// The component index. + public float this[int index] + { + get + { + switch (index) + { + case 0: + return x; + case 1: + return y; + case 2: + return z; + default: + throw new IndexOutOfRangeException("Invalid Vector3 index!"); + } + } + set + { + switch (index) + { + case 0: + x = value; + break; + case 1: + y = value; + break; + case 2: + z = value; + break; + default: + throw new IndexOutOfRangeException("Invalid Vector3 index!"); + } + } + } + #endregion + + #region Constructor + /// + /// Creates a new vector with one value for all components. + /// + /// The value. + public Vector3(float value) + { + this.x = value; + this.y = value; + this.z = value; + } + + /// + /// Creates a new vector. + /// + /// The x value. + /// The y value. + /// The z value. + public Vector3(float x, float y, float z) + { + this.x = x; + this.y = y; + this.z = z; + } + + /// + /// Creates a new vector from a double precision vector. + /// + /// The double precision vector. + public Vector3(Vector3d vector) + { + this.x = (float)vector.x; + this.y = (float)vector.y; + this.z = (float)vector.z; + } + #endregion + + #region Operators + /// + /// Adds two vectors. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static Vector3 operator +(Vector3 a, Vector3 b) + { + return new Vector3(a.x + b.x, a.y + b.y, a.z + b.z); + } + + /// + /// Subtracts two vectors. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static Vector3 operator -(Vector3 a, Vector3 b) + { + return new Vector3(a.x - b.x, a.y - b.y, a.z - b.z); + } + + /// + /// Scales the vector uniformly. + /// + /// The vector. + /// The scaling value. + /// The resulting vector. + public static Vector3 operator *(Vector3 a, float d) + { + return new Vector3(a.x * d, a.y * d, a.z * d); + } + + /// + /// Scales the vector uniformly. + /// + /// The scaling value. + /// The vector. + /// The resulting vector. + public static Vector3 operator *(float d, Vector3 a) + { + return new Vector3(a.x * d, a.y * d, a.z * d); + } + + /// + /// Divides the vector with a float. + /// + /// The vector. + /// The dividing float value. + /// The resulting vector. + public static Vector3 operator /(Vector3 a, float d) + { + return new Vector3(a.x / d, a.y / d, a.z / d); + } + + /// + /// Subtracts the vector from a zero vector. + /// + /// The vector. + /// The resulting vector. + public static Vector3 operator -(Vector3 a) + { + return new Vector3(-a.x, -a.y, -a.z); + } + + /// + /// Returns if two vectors equals eachother. + /// + /// The left hand side vector. + /// The right hand side vector. + /// If equals. + public static bool operator ==(Vector3 lhs, Vector3 rhs) + { + return (lhs - rhs).MagnitudeSqr < Epsilon; + } + + /// + /// Returns if two vectors don't equal eachother. + /// + /// The left hand side vector. + /// The right hand side vector. + /// If not equals. + public static bool operator !=(Vector3 lhs, Vector3 rhs) + { + return (lhs - rhs).MagnitudeSqr >= Epsilon; + } + + /// + /// Explicitly converts from a double-precision vector into a single-precision vector. + /// + /// The double-precision vector. + public static explicit operator Vector3(Vector3d v) + { + return new Vector3((float)v.x, (float)v.y, (float)v.z); + } + + /// + /// Implicitly converts from an integer vector into a single-precision vector. + /// + /// The integer vector. + public static implicit operator Vector3(Vector3i v) + { + return new Vector3(v.x, v.y, v.z); + } + #endregion + + #region Public Methods + #region Instance + /// + /// Set x, y and z components of an existing vector. + /// + /// The x value. + /// The y value. + /// The z value. + public void Set(float x, float y, float z) + { + this.x = x; + this.y = y; + this.z = z; + } + + /// + /// Multiplies with another vector component-wise. + /// + /// The vector to multiply with. + public void Scale(ref Vector3 scale) + { + x *= scale.x; + y *= scale.y; + z *= scale.z; + } + + /// + /// Normalizes this vector. + /// + public void Normalize() + { + float mag = this.Magnitude; + if (mag > Epsilon) + { + x /= mag; + y /= mag; + z /= mag; + } + else + { + x = y = z = 0; + } + } + + /// + /// Clamps this vector between a specific range. + /// + /// The minimum component value. + /// The maximum component value. + public void Clamp(float min, float max) + { + if (x < min) x = min; + else if (x > max) x = max; + + if (y < min) y = min; + else if (y > max) y = max; + + if (z < min) z = min; + else if (z > max) z = max; + } + #endregion + + #region Object + /// + /// Returns a hash code for this vector. + /// + /// The hash code. + public override int GetHashCode() + { + return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2; + } + + /// + /// Returns if this vector is equal to another one. + /// + /// The other vector to compare to. + /// If equals. + public override bool Equals(object other) + { + if (!(other is Vector3)) + { + return false; + } + Vector3 vector = (Vector3)other; + return (x == vector.x && y == vector.y && z == vector.z); + } + + /// + /// Returns if this vector is equal to another one. + /// + /// The other vector to compare to. + /// If equals. + public bool Equals(Vector3 other) + { + return (x == other.x && y == other.y && z == other.z); + } + + /// + /// Returns a nicely formatted string for this vector. + /// + /// The string. + public override string ToString() + { + return string.Format("({0}, {1}, {2})", + x.ToString("F1", CultureInfo.InvariantCulture), + y.ToString("F1", CultureInfo.InvariantCulture), + z.ToString("F1", CultureInfo.InvariantCulture)); + } + + /// + /// Returns a nicely formatted string for this vector. + /// + /// The float format. + /// The string. + public string ToString(string format) + { + return string.Format("({0}, {1}, {2})", + x.ToString(format, CultureInfo.InvariantCulture), + y.ToString(format, CultureInfo.InvariantCulture), + z.ToString(format, CultureInfo.InvariantCulture)); + } + #endregion + + #region Static + /// + /// Dot Product of two vectors. + /// + /// The left hand side vector. + /// The right hand side vector. + public static float Dot(ref Vector3 lhs, ref Vector3 rhs) + { + return lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z; + } + + /// + /// Cross Product of two vectors. + /// + /// The left hand side vector. + /// The right hand side vector. + /// The resulting vector. + public static void Cross(ref Vector3 lhs, ref Vector3 rhs, out Vector3 result) + { + result = new Vector3(lhs.y * rhs.z - lhs.z * rhs.y, lhs.z * rhs.x - lhs.x * rhs.z, lhs.x * rhs.y - lhs.y * rhs.x); + } + + /// + /// Calculates the angle between two vectors. + /// + /// The from vector. + /// The to vector. + /// The angle. + public static float Angle(ref Vector3 from, ref Vector3 to) + { + Vector3 fromNormalized = from.Normalized; + Vector3 toNormalized = to.Normalized; + return (float)System.Math.Acos(MathHelper.Clamp(Vector3.Dot(ref fromNormalized, ref toNormalized), -1f, 1f)) * MathHelper.Rad2Deg; + } + + /// + /// Performs a linear interpolation between two vectors. + /// + /// The vector to interpolate from. + /// The vector to interpolate to. + /// The time fraction. + /// The resulting vector. + public static void Lerp(ref Vector3 a, ref Vector3 b, float t, out Vector3 result) + { + result = new Vector3(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t); + } + + /// + /// Multiplies two vectors component-wise. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static void Scale(ref Vector3 a, ref Vector3 b, out Vector3 result) + { + result = new Vector3(a.x * b.x, a.y * b.y, a.z * b.z); + } + + /// + /// Normalizes a vector. + /// + /// The vector to normalize. + /// The resulting normalized vector. + public static void Normalize(ref Vector3 value, out Vector3 result) + { + float mag = value.Magnitude; + if (mag > Epsilon) + { + result = new Vector3(value.x / mag, value.y / mag, value.z / mag); + } + else + { + result = Vector3.zero; + } + } + + /// + /// Normalizes both vectors and makes them orthogonal to each other. + /// + /// The normal vector. + /// The tangent. + public static void OrthoNormalize(ref Vector3 normal, ref Vector3 tangent) + { + normal.Normalize(); + Vector3 proj = normal * Vector3.Dot(ref tangent, ref normal); + tangent -= proj; + tangent.Normalize(); + } + #endregion + #endregion + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector3d.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector3d.cs new file mode 100644 index 0000000..11ebed1 --- /dev/null +++ b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector3d.cs @@ -0,0 +1,481 @@ +#region License +/* +MIT License + +Copyright(c) 2017-2018 Mattias Edlund + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +#endregion + +using System; +using System.Globalization; + +namespace MeshDecimator.Math +{ + /// + /// A double precision 3D vector. + /// + public struct Vector3d : IEquatable + { + #region Static Read-Only + /// + /// The zero vector. + /// + public static readonly Vector3d zero = new Vector3d(0, 0, 0); + #endregion + + #region Consts + /// + /// The vector epsilon. + /// + public const double Epsilon = double.Epsilon; + #endregion + + #region Fields + /// + /// The x component. + /// + public double x; + /// + /// The y component. + /// + public double y; + /// + /// The z component. + /// + public double z; + #endregion + + #region Properties + /// + /// Gets the magnitude of this vector. + /// + public double Magnitude + { + get { return System.Math.Sqrt(x * x + y * y + z * z); } + } + + /// + /// Gets the squared magnitude of this vector. + /// + public double MagnitudeSqr + { + get { return (x * x + y * y + z * z); } + } + + /// + /// Gets a normalized vector from this vector. + /// + public Vector3d Normalized + { + get + { + Vector3d result; + Normalize(ref this, out result); + return result; + } + } + + /// + /// Gets or sets a specific component by index in this vector. + /// + /// The component index. + public double this[int index] + { + get + { + switch (index) + { + case 0: + return x; + case 1: + return y; + case 2: + return z; + default: + throw new IndexOutOfRangeException("Invalid Vector3d index!"); + } + } + set + { + switch (index) + { + case 0: + x = value; + break; + case 1: + y = value; + break; + case 2: + z = value; + break; + default: + throw new IndexOutOfRangeException("Invalid Vector3d index!"); + } + } + } + #endregion + + #region Constructor + /// + /// Creates a new vector with one value for all components. + /// + /// The value. + public Vector3d(double value) + { + this.x = value; + this.y = value; + this.z = value; + } + + /// + /// Creates a new vector. + /// + /// The x value. + /// The y value. + /// The z value. + public Vector3d(double x, double y, double z) + { + this.x = x; + this.y = y; + this.z = z; + } + + /// + /// Creates a new vector from a single precision vector. + /// + /// The single precision vector. + public Vector3d(Vector3 vector) + { + this.x = vector.x; + this.y = vector.y; + this.z = vector.z; + } + #endregion + + #region Operators + /// + /// Adds two vectors. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static Vector3d operator +(Vector3d a, Vector3d b) + { + return new Vector3d(a.x + b.x, a.y + b.y, a.z + b.z); + } + + /// + /// Subtracts two vectors. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static Vector3d operator -(Vector3d a, Vector3d b) + { + return new Vector3d(a.x - b.x, a.y - b.y, a.z - b.z); + } + + /// + /// Scales the vector uniformly. + /// + /// The vector. + /// The scaling value. + /// The resulting vector. + public static Vector3d operator *(Vector3d a, double d) + { + return new Vector3d(a.x * d, a.y * d, a.z * d); + } + + /// + /// Scales the vector uniformly. + /// + /// The scaling value. + /// The vector. + /// The resulting vector. + public static Vector3d operator *(double d, Vector3d a) + { + return new Vector3d(a.x * d, a.y * d, a.z * d); + } + + /// + /// Divides the vector with a float. + /// + /// The vector. + /// The dividing float value. + /// The resulting vector. + public static Vector3d operator /(Vector3d a, double d) + { + return new Vector3d(a.x / d, a.y / d, a.z / d); + } + + /// + /// Subtracts the vector from a zero vector. + /// + /// The vector. + /// The resulting vector. + public static Vector3d operator -(Vector3d a) + { + return new Vector3d(-a.x, -a.y, -a.z); + } + + /// + /// Returns if two vectors equals eachother. + /// + /// The left hand side vector. + /// The right hand side vector. + /// If equals. + public static bool operator ==(Vector3d lhs, Vector3d rhs) + { + return (lhs - rhs).MagnitudeSqr < Epsilon; + } + + /// + /// Returns if two vectors don't equal eachother. + /// + /// The left hand side vector. + /// The right hand side vector. + /// If not equals. + public static bool operator !=(Vector3d lhs, Vector3d rhs) + { + return (lhs - rhs).MagnitudeSqr >= Epsilon; + } + + /// + /// Implicitly converts from a single-precision vector into a double-precision vector. + /// + /// The single-precision vector. + public static implicit operator Vector3d(Vector3 v) + { + return new Vector3d(v.x, v.y, v.z); + } + + /// + /// Implicitly converts from an integer vector into a double-precision vector. + /// + /// The integer vector. + public static implicit operator Vector3d(Vector3i v) + { + return new Vector3d(v.x, v.y, v.z); + } + #endregion + + #region Public Methods + #region Instance + /// + /// Set x, y and z components of an existing vector. + /// + /// The x value. + /// The y value. + /// The z value. + public void Set(double x, double y, double z) + { + this.x = x; + this.y = y; + this.z = z; + } + + /// + /// Multiplies with another vector component-wise. + /// + /// The vector to multiply with. + public void Scale(ref Vector3d scale) + { + x *= scale.x; + y *= scale.y; + z *= scale.z; + } + + /// + /// Normalizes this vector. + /// + public void Normalize() + { + double mag = this.Magnitude; + if (mag > Epsilon) + { + x /= mag; + y /= mag; + z /= mag; + } + else + { + x = y = z = 0; + } + } + + /// + /// Clamps this vector between a specific range. + /// + /// The minimum component value. + /// The maximum component value. + public void Clamp(double min, double max) + { + if (x < min) x = min; + else if (x > max) x = max; + + if (y < min) y = min; + else if (y > max) y = max; + + if (z < min) z = min; + else if (z > max) z = max; + } + #endregion + + #region Object + /// + /// Returns a hash code for this vector. + /// + /// The hash code. + public override int GetHashCode() + { + return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2; + } + + /// + /// Returns if this vector is equal to another one. + /// + /// The other vector to compare to. + /// If equals. + public override bool Equals(object other) + { + if (!(other is Vector3d)) + { + return false; + } + Vector3d vector = (Vector3d)other; + return (x == vector.x && y == vector.y && z == vector.z); + } + + /// + /// Returns if this vector is equal to another one. + /// + /// The other vector to compare to. + /// If equals. + public bool Equals(Vector3d other) + { + return (x == other.x && y == other.y && z == other.z); + } + + /// + /// Returns a nicely formatted string for this vector. + /// + /// The string. + public override string ToString() + { + return string.Format("({0}, {1}, {2})", + x.ToString("F1", CultureInfo.InvariantCulture), + y.ToString("F1", CultureInfo.InvariantCulture), + z.ToString("F1", CultureInfo.InvariantCulture)); + } + + /// + /// Returns a nicely formatted string for this vector. + /// + /// The float format. + /// The string. + public string ToString(string format) + { + return string.Format("({0}, {1}, {2})", + x.ToString(format, CultureInfo.InvariantCulture), + y.ToString(format, CultureInfo.InvariantCulture), + z.ToString(format, CultureInfo.InvariantCulture)); + } + #endregion + + #region Static + /// + /// Dot Product of two vectors. + /// + /// The left hand side vector. + /// The right hand side vector. + public static double Dot(ref Vector3d lhs, ref Vector3d rhs) + { + return lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z; + } + + /// + /// Cross Product of two vectors. + /// + /// The left hand side vector. + /// The right hand side vector. + /// The resulting vector. + public static void Cross(ref Vector3d lhs, ref Vector3d rhs, out Vector3d result) + { + result = new Vector3d(lhs.y * rhs.z - lhs.z * rhs.y, lhs.z * rhs.x - lhs.x * rhs.z, lhs.x * rhs.y - lhs.y * rhs.x); + } + + /// + /// Calculates the angle between two vectors. + /// + /// The from vector. + /// The to vector. + /// The angle. + public static double Angle(ref Vector3d from, ref Vector3d to) + { + Vector3d fromNormalized = from.Normalized; + Vector3d toNormalized = to.Normalized; + return System.Math.Acos(MathHelper.Clamp(Vector3d.Dot(ref fromNormalized, ref toNormalized), -1.0, 1.0)) * MathHelper.Rad2Degd; + } + + /// + /// Performs a linear interpolation between two vectors. + /// + /// The vector to interpolate from. + /// The vector to interpolate to. + /// The time fraction. + /// The resulting vector. + public static void Lerp(ref Vector3d a, ref Vector3d b, double t, out Vector3d result) + { + result = new Vector3d(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t); + } + + /// + /// Multiplies two vectors component-wise. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static void Scale(ref Vector3d a, ref Vector3d b, out Vector3d result) + { + result = new Vector3d(a.x * b.x, a.y * b.y, a.z * b.z); + } + + /// + /// Normalizes a vector. + /// + /// The vector to normalize. + /// The resulting normalized vector. + public static void Normalize(ref Vector3d value, out Vector3d result) + { + double mag = value.Magnitude; + if (mag > Epsilon) + { + result = new Vector3d(value.x / mag, value.y / mag, value.z / mag); + } + else + { + result = Vector3d.zero; + } + } + #endregion + #endregion + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector3i.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector3i.cs new file mode 100644 index 0000000..d36d6d1 --- /dev/null +++ b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector3i.cs @@ -0,0 +1,368 @@ +#region License +/* +MIT License + +Copyright(c) 2017-2018 Mattias Edlund + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +#endregion + +using System; +using System.Globalization; + +namespace MeshDecimator.Math +{ + /// + /// A 3D integer vector. + /// + public struct Vector3i : IEquatable + { + #region Static Read-Only + /// + /// The zero vector. + /// + public static readonly Vector3i zero = new Vector3i(0, 0, 0); + #endregion + + #region Fields + /// + /// The x component. + /// + public int x; + /// + /// The y component. + /// + public int y; + /// + /// The z component. + /// + public int z; + #endregion + + #region Properties + /// + /// Gets the magnitude of this vector. + /// + public int Magnitude + { + get { return (int)System.Math.Sqrt(x * x + y * y + z * z); } + } + + /// + /// Gets the squared magnitude of this vector. + /// + public int MagnitudeSqr + { + get { return (x * x + y * y + z * z); } + } + + /// + /// Gets or sets a specific component by index in this vector. + /// + /// The component index. + public int this[int index] + { + get + { + switch (index) + { + case 0: + return x; + case 1: + return y; + case 2: + return z; + default: + throw new IndexOutOfRangeException("Invalid Vector3i index!"); + } + } + set + { + switch (index) + { + case 0: + x = value; + break; + case 1: + y = value; + break; + case 2: + z = value; + break; + default: + throw new IndexOutOfRangeException("Invalid Vector3i index!"); + } + } + } + #endregion + + #region Constructor + /// + /// Creates a new vector with one value for all components. + /// + /// The value. + public Vector3i(int value) + { + this.x = value; + this.y = value; + this.z = value; + } + + /// + /// Creates a new vector. + /// + /// The x value. + /// The y value. + /// The z value. + public Vector3i(int x, int y, int z) + { + this.x = x; + this.y = y; + this.z = z; + } + #endregion + + #region Operators + /// + /// Adds two vectors. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static Vector3i operator +(Vector3i a, Vector3i b) + { + return new Vector3i(a.x + b.x, a.y + b.y, a.z + b.z); + } + + /// + /// Subtracts two vectors. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static Vector3i operator -(Vector3i a, Vector3i b) + { + return new Vector3i(a.x - b.x, a.y - b.y, a.z - b.z); + } + + /// + /// Scales the vector uniformly. + /// + /// The vector. + /// The scaling value. + /// The resulting vector. + public static Vector3i operator *(Vector3i a, int d) + { + return new Vector3i(a.x * d, a.y * d, a.z * d); + } + + /// + /// Scales the vector uniformly. + /// + /// The scaling value. + /// The vector. + /// The resulting vector. + public static Vector3i operator *(int d, Vector3i a) + { + return new Vector3i(a.x * d, a.y * d, a.z * d); + } + + /// + /// Divides the vector with a float. + /// + /// The vector. + /// The dividing float value. + /// The resulting vector. + public static Vector3i operator /(Vector3i a, int d) + { + return new Vector3i(a.x / d, a.y / d, a.z / d); + } + + /// + /// Subtracts the vector from a zero vector. + /// + /// The vector. + /// The resulting vector. + public static Vector3i operator -(Vector3i a) + { + return new Vector3i(-a.x, -a.y, -a.z); + } + + /// + /// Returns if two vectors equals eachother. + /// + /// The left hand side vector. + /// The right hand side vector. + /// If equals. + public static bool operator ==(Vector3i lhs, Vector3i rhs) + { + return (lhs.x == rhs.x && lhs.y == rhs.y && lhs.z == rhs.z); + } + + /// + /// Returns if two vectors don't equal eachother. + /// + /// The left hand side vector. + /// The right hand side vector. + /// If not equals. + public static bool operator !=(Vector3i lhs, Vector3i rhs) + { + return (lhs.x != rhs.x || lhs.y != rhs.y || lhs.z != rhs.z); + } + + /// + /// Explicitly converts from a single-precision vector into an integer vector. + /// + /// The single-precision vector. + public static implicit operator Vector3i(Vector3 v) + { + return new Vector3i((int)v.x, (int)v.y, (int)v.z); + } + + /// + /// Explicitly converts from a double-precision vector into an integer vector. + /// + /// The double-precision vector. + public static explicit operator Vector3i(Vector3d v) + { + return new Vector3i((int)v.x, (int)v.y, (int)v.z); + } + #endregion + + #region Public Methods + #region Instance + /// + /// Set x, y and z components of an existing vector. + /// + /// The x value. + /// The y value. + /// The z value. + public void Set(int x, int y, int z) + { + this.x = x; + this.y = y; + this.z = z; + } + + /// + /// Multiplies with another vector component-wise. + /// + /// The vector to multiply with. + public void Scale(ref Vector3i scale) + { + x *= scale.x; + y *= scale.y; + z *= scale.z; + } + + /// + /// Clamps this vector between a specific range. + /// + /// The minimum component value. + /// The maximum component value. + public void Clamp(int min, int max) + { + if (x < min) x = min; + else if (x > max) x = max; + + if (y < min) y = min; + else if (y > max) y = max; + + if (z < min) z = min; + else if (z > max) z = max; + } + #endregion + + #region Object + /// + /// Returns a hash code for this vector. + /// + /// The hash code. + public override int GetHashCode() + { + return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2; + } + + /// + /// Returns if this vector is equal to another one. + /// + /// The other vector to compare to. + /// If equals. + public override bool Equals(object other) + { + if (!(other is Vector3i)) + { + return false; + } + Vector3i vector = (Vector3i)other; + return (x == vector.x && y == vector.y && z == vector.z); + } + + /// + /// Returns if this vector is equal to another one. + /// + /// The other vector to compare to. + /// If equals. + public bool Equals(Vector3i other) + { + return (x == other.x && y == other.y && z == other.z); + } + + /// + /// Returns a nicely formatted string for this vector. + /// + /// The string. + public override string ToString() + { + return string.Format("({0}, {1}, {2})", + x.ToString(CultureInfo.InvariantCulture), + y.ToString(CultureInfo.InvariantCulture), + z.ToString(CultureInfo.InvariantCulture)); + } + + /// + /// Returns a nicely formatted string for this vector. + /// + /// The integer format. + /// The string. + public string ToString(string format) + { + return string.Format("({0}, {1}, {2})", + x.ToString(format, CultureInfo.InvariantCulture), + y.ToString(format, CultureInfo.InvariantCulture), + z.ToString(format, CultureInfo.InvariantCulture)); + } + #endregion + + #region Static + /// + /// Multiplies two vectors component-wise. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static void Scale(ref Vector3i a, ref Vector3i b, out Vector3i result) + { + result = new Vector3i(a.x * b.x, a.y * b.y, a.z * b.z); + } + #endregion + #endregion + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector4.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector4.cs new file mode 100644 index 0000000..bf1d655 --- /dev/null +++ b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector4.cs @@ -0,0 +1,467 @@ +#region License +/* +MIT License + +Copyright(c) 2017-2018 Mattias Edlund + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +#endregion + +using System; +using System.Globalization; + +namespace MeshDecimator.Math +{ + /// + /// A single precision 4D vector. + /// + public struct Vector4 : IEquatable + { + #region Static Read-Only + /// + /// The zero vector. + /// + public static readonly Vector4 zero = new Vector4(0, 0, 0, 0); + #endregion + + #region Consts + /// + /// The vector epsilon. + /// + public const float Epsilon = 9.99999944E-11f; + #endregion + + #region Fields + /// + /// The x component. + /// + public float x; + /// + /// The y component. + /// + public float y; + /// + /// The z component. + /// + public float z; + /// + /// The w component. + /// + public float w; + #endregion + + #region Properties + /// + /// Gets the magnitude of this vector. + /// + public float Magnitude + { + get { return (float)System.Math.Sqrt(x * x + y * y + z * z + w * w); } + } + + /// + /// Gets the squared magnitude of this vector. + /// + public float MagnitudeSqr + { + get { return (x * x + y * y + z * z + w * w); } + } + + /// + /// Gets a normalized vector from this vector. + /// + public Vector4 Normalized + { + get + { + Vector4 result; + Normalize(ref this, out result); + return result; + } + } + + /// + /// Gets or sets a specific component by index in this vector. + /// + /// The component index. + public float this[int index] + { + get + { + switch (index) + { + case 0: + return x; + case 1: + return y; + case 2: + return z; + case 3: + return w; + default: + throw new IndexOutOfRangeException("Invalid Vector4 index!"); + } + } + set + { + switch (index) + { + case 0: + x = value; + break; + case 1: + y = value; + break; + case 2: + z = value; + break; + case 3: + w = value; + break; + default: + throw new IndexOutOfRangeException("Invalid Vector4 index!"); + } + } + } + #endregion + + #region Constructor + /// + /// Creates a new vector with one value for all components. + /// + /// The value. + public Vector4(float value) + { + this.x = value; + this.y = value; + this.z = value; + this.w = value; + } + + /// + /// Creates a new vector. + /// + /// The x value. + /// The y value. + /// The z value. + /// The w value. + public Vector4(float x, float y, float z, float w) + { + this.x = x; + this.y = y; + this.z = z; + this.w = w; + } + #endregion + + #region Operators + /// + /// Adds two vectors. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static Vector4 operator +(Vector4 a, Vector4 b) + { + return new Vector4(a.x + b.x, a.y + b.y, a.z + b.z, a.w + b.w); + } + + /// + /// Subtracts two vectors. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static Vector4 operator -(Vector4 a, Vector4 b) + { + return new Vector4(a.x - b.x, a.y - b.y, a.z - b.z, a.w - b.w); + } + + /// + /// Scales the vector uniformly. + /// + /// The vector. + /// The scaling value. + /// The resulting vector. + public static Vector4 operator *(Vector4 a, float d) + { + return new Vector4(a.x * d, a.y * d, a.z * d, a.w * d); + } + + /// + /// Scales the vector uniformly. + /// + /// The scaling value. + /// The vector. + /// The resulting vector. + public static Vector4 operator *(float d, Vector4 a) + { + return new Vector4(a.x * d, a.y * d, a.z * d, a.w * d); + } + + /// + /// Divides the vector with a float. + /// + /// The vector. + /// The dividing float value. + /// The resulting vector. + public static Vector4 operator /(Vector4 a, float d) + { + return new Vector4(a.x / d, a.y / d, a.z / d, a.w / d); + } + + /// + /// Subtracts the vector from a zero vector. + /// + /// The vector. + /// The resulting vector. + public static Vector4 operator -(Vector4 a) + { + return new Vector4(-a.x, -a.y, -a.z, -a.w); + } + + /// + /// Returns if two vectors equals eachother. + /// + /// The left hand side vector. + /// The right hand side vector. + /// If equals. + public static bool operator ==(Vector4 lhs, Vector4 rhs) + { + return (lhs - rhs).MagnitudeSqr < Epsilon; + } + + /// + /// Returns if two vectors don't equal eachother. + /// + /// The left hand side vector. + /// The right hand side vector. + /// If not equals. + public static bool operator !=(Vector4 lhs, Vector4 rhs) + { + return (lhs - rhs).MagnitudeSqr >= Epsilon; + } + + /// + /// Explicitly converts from a double-precision vector into a single-precision vector. + /// + /// The double-precision vector. + public static explicit operator Vector4(Vector4d v) + { + return new Vector4((float)v.x, (float)v.y, (float)v.z, (float)v.w); + } + + /// + /// Implicitly converts from an integer vector into a single-precision vector. + /// + /// The integer vector. + public static implicit operator Vector4(Vector4i v) + { + return new Vector4(v.x, v.y, v.z, v.w); + } + #endregion + + #region Public Methods + #region Instance + /// + /// Set x, y and z components of an existing vector. + /// + /// The x value. + /// The y value. + /// The z value. + /// The w value. + public void Set(float x, float y, float z, float w) + { + this.x = x; + this.y = y; + this.z = z; + this.w = w; + } + + /// + /// Multiplies with another vector component-wise. + /// + /// The vector to multiply with. + public void Scale(ref Vector4 scale) + { + x *= scale.x; + y *= scale.y; + z *= scale.z; + w *= scale.w; + } + + /// + /// Normalizes this vector. + /// + public void Normalize() + { + float mag = this.Magnitude; + if (mag > Epsilon) + { + x /= mag; + y /= mag; + z /= mag; + w /= mag; + } + else + { + x = y = z = w = 0; + } + } + + /// + /// Clamps this vector between a specific range. + /// + /// The minimum component value. + /// The maximum component value. + public void Clamp(float min, float max) + { + if (x < min) x = min; + else if (x > max) x = max; + + if (y < min) y = min; + else if (y > max) y = max; + + if (z < min) z = min; + else if (z > max) z = max; + + if (w < min) w = min; + else if (w > max) w = max; + } + #endregion + + #region Object + /// + /// Returns a hash code for this vector. + /// + /// The hash code. + public override int GetHashCode() + { + return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2 ^ w.GetHashCode() >> 1; + } + + /// + /// Returns if this vector is equal to another one. + /// + /// The other vector to compare to. + /// If equals. + public override bool Equals(object other) + { + if (!(other is Vector4)) + { + return false; + } + Vector4 vector = (Vector4)other; + return (x == vector.x && y == vector.y && z == vector.z && w == vector.w); + } + + /// + /// Returns if this vector is equal to another one. + /// + /// The other vector to compare to. + /// If equals. + public bool Equals(Vector4 other) + { + return (x == other.x && y == other.y && z == other.z && w == other.w); + } + + /// + /// Returns a nicely formatted string for this vector. + /// + /// The string. + public override string ToString() + { + return string.Format("({0}, {1}, {2}, {3})", + x.ToString("F1", CultureInfo.InvariantCulture), + y.ToString("F1", CultureInfo.InvariantCulture), + z.ToString("F1", CultureInfo.InvariantCulture), + w.ToString("F1", CultureInfo.InvariantCulture)); + } + + /// + /// Returns a nicely formatted string for this vector. + /// + /// The float format. + /// The string. + public string ToString(string format) + { + return string.Format("({0}, {1}, {2}, {3})", + x.ToString(format, CultureInfo.InvariantCulture), + y.ToString(format, CultureInfo.InvariantCulture), + z.ToString(format, CultureInfo.InvariantCulture), + w.ToString(format, CultureInfo.InvariantCulture)); + } + #endregion + + #region Static + /// + /// Dot Product of two vectors. + /// + /// The left hand side vector. + /// The right hand side vector. + public static float Dot(ref Vector4 lhs, ref Vector4 rhs) + { + return lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z + lhs.w * rhs.w; + } + + /// + /// Performs a linear interpolation between two vectors. + /// + /// The vector to interpolate from. + /// The vector to interpolate to. + /// The time fraction. + /// The resulting vector. + public static void Lerp(ref Vector4 a, ref Vector4 b, float t, out Vector4 result) + { + result = new Vector4(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t, a.w + (b.w - a.w) * t); + } + + /// + /// Multiplies two vectors component-wise. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static void Scale(ref Vector4 a, ref Vector4 b, out Vector4 result) + { + result = new Vector4(a.x * b.x, a.y * b.y, a.z * b.z, a.w * b.w); + } + + /// + /// Normalizes a vector. + /// + /// The vector to normalize. + /// The resulting normalized vector. + public static void Normalize(ref Vector4 value, out Vector4 result) + { + float mag = value.Magnitude; + if (mag > Epsilon) + { + result = new Vector4(value.x / mag, value.y / mag, value.z / mag, value.w / mag); + } + else + { + result = Vector4.zero; + } + } + #endregion + #endregion + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector4d.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector4d.cs new file mode 100644 index 0000000..c984c08 --- /dev/null +++ b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector4d.cs @@ -0,0 +1,467 @@ +#region License +/* +MIT License + +Copyright(c) 2017-2018 Mattias Edlund + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +#endregion + +using System; +using System.Globalization; + +namespace MeshDecimator.Math +{ + /// + /// A double precision 4D vector. + /// + public struct Vector4d : IEquatable + { + #region Static Read-Only + /// + /// The zero vector. + /// + public static readonly Vector4d zero = new Vector4d(0, 0, 0, 0); + #endregion + + #region Consts + /// + /// The vector epsilon. + /// + public const double Epsilon = double.Epsilon; + #endregion + + #region Fields + /// + /// The x component. + /// + public double x; + /// + /// The y component. + /// + public double y; + /// + /// The z component. + /// + public double z; + /// + /// The w component. + /// + public double w; + #endregion + + #region Properties + /// + /// Gets the magnitude of this vector. + /// + public double Magnitude + { + get { return System.Math.Sqrt(x * x + y * y + z * z + w * w); } + } + + /// + /// Gets the squared magnitude of this vector. + /// + public double MagnitudeSqr + { + get { return (x * x + y * y + z * z + w * w); } + } + + /// + /// Gets a normalized vector from this vector. + /// + public Vector4d Normalized + { + get + { + Vector4d result; + Normalize(ref this, out result); + return result; + } + } + + /// + /// Gets or sets a specific component by index in this vector. + /// + /// The component index. + public double this[int index] + { + get + { + switch (index) + { + case 0: + return x; + case 1: + return y; + case 2: + return z; + case 3: + return w; + default: + throw new IndexOutOfRangeException("Invalid Vector4d index!"); + } + } + set + { + switch (index) + { + case 0: + x = value; + break; + case 1: + y = value; + break; + case 2: + z = value; + break; + case 3: + w = value; + break; + default: + throw new IndexOutOfRangeException("Invalid Vector4d index!"); + } + } + } + #endregion + + #region Constructor + /// + /// Creates a new vector with one value for all components. + /// + /// The value. + public Vector4d(double value) + { + this.x = value; + this.y = value; + this.z = value; + this.w = value; + } + + /// + /// Creates a new vector. + /// + /// The x value. + /// The y value. + /// The z value. + /// The w value. + public Vector4d(double x, double y, double z, double w) + { + this.x = x; + this.y = y; + this.z = z; + this.w = w; + } + #endregion + + #region Operators + /// + /// Adds two vectors. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static Vector4d operator +(Vector4d a, Vector4d b) + { + return new Vector4d(a.x + b.x, a.y + b.y, a.z + b.z, a.w + b.w); + } + + /// + /// Subtracts two vectors. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static Vector4d operator -(Vector4d a, Vector4d b) + { + return new Vector4d(a.x - b.x, a.y - b.y, a.z - b.z, a.w - b.w); + } + + /// + /// Scales the vector uniformly. + /// + /// The vector. + /// The scaling value. + /// The resulting vector. + public static Vector4d operator *(Vector4d a, double d) + { + return new Vector4d(a.x * d, a.y * d, a.z * d, a.w * d); + } + + /// + /// Scales the vector uniformly. + /// + /// The scaling value. + /// The vector. + /// The resulting vector. + public static Vector4d operator *(double d, Vector4d a) + { + return new Vector4d(a.x * d, a.y * d, a.z * d, a.w * d); + } + + /// + /// Divides the vector with a float. + /// + /// The vector. + /// The dividing float value. + /// The resulting vector. + public static Vector4d operator /(Vector4d a, double d) + { + return new Vector4d(a.x / d, a.y / d, a.z / d, a.w / d); + } + + /// + /// Subtracts the vector from a zero vector. + /// + /// The vector. + /// The resulting vector. + public static Vector4d operator -(Vector4d a) + { + return new Vector4d(-a.x, -a.y, -a.z, -a.w); + } + + /// + /// Returns if two vectors equals eachother. + /// + /// The left hand side vector. + /// The right hand side vector. + /// If equals. + public static bool operator ==(Vector4d lhs, Vector4d rhs) + { + return (lhs - rhs).MagnitudeSqr < Epsilon; + } + + /// + /// Returns if two vectors don't equal eachother. + /// + /// The left hand side vector. + /// The right hand side vector. + /// If not equals. + public static bool operator !=(Vector4d lhs, Vector4d rhs) + { + return (lhs - rhs).MagnitudeSqr >= Epsilon; + } + + /// + /// Implicitly converts from a single-precision vector into a double-precision vector. + /// + /// The single-precision vector. + public static implicit operator Vector4d(Vector4 v) + { + return new Vector4d(v.x, v.y, v.z, v.w); + } + + /// + /// Implicitly converts from an integer vector into a double-precision vector. + /// + /// The integer vector. + public static implicit operator Vector4d(Vector4i v) + { + return new Vector4d(v.x, v.y, v.z, v.w); + } + #endregion + + #region Public Methods + #region Instance + /// + /// Set x, y and z components of an existing vector. + /// + /// The x value. + /// The y value. + /// The z value. + /// The w value. + public void Set(double x, double y, double z, double w) + { + this.x = x; + this.y = y; + this.z = z; + this.w = w; + } + + /// + /// Multiplies with another vector component-wise. + /// + /// The vector to multiply with. + public void Scale(ref Vector4d scale) + { + x *= scale.x; + y *= scale.y; + z *= scale.z; + w *= scale.w; + } + + /// + /// Normalizes this vector. + /// + public void Normalize() + { + double mag = this.Magnitude; + if (mag > Epsilon) + { + x /= mag; + y /= mag; + z /= mag; + w /= mag; + } + else + { + x = y = z = w = 0; + } + } + + /// + /// Clamps this vector between a specific range. + /// + /// The minimum component value. + /// The maximum component value. + public void Clamp(double min, double max) + { + if (x < min) x = min; + else if (x > max) x = max; + + if (y < min) y = min; + else if (y > max) y = max; + + if (z < min) z = min; + else if (z > max) z = max; + + if (w < min) w = min; + else if (w > max) w = max; + } + #endregion + + #region Object + /// + /// Returns a hash code for this vector. + /// + /// The hash code. + public override int GetHashCode() + { + return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2 ^ w.GetHashCode() >> 1; + } + + /// + /// Returns if this vector is equal to another one. + /// + /// The other vector to compare to. + /// If equals. + public override bool Equals(object other) + { + if (!(other is Vector4d)) + { + return false; + } + Vector4d vector = (Vector4d)other; + return (x == vector.x && y == vector.y && z == vector.z && w == vector.w); + } + + /// + /// Returns if this vector is equal to another one. + /// + /// The other vector to compare to. + /// If equals. + public bool Equals(Vector4d other) + { + return (x == other.x && y == other.y && z == other.z && w == other.w); + } + + /// + /// Returns a nicely formatted string for this vector. + /// + /// The string. + public override string ToString() + { + return string.Format("({0}, {1}, {2}, {3})", + x.ToString("F1", CultureInfo.InvariantCulture), + y.ToString("F1", CultureInfo.InvariantCulture), + z.ToString("F1", CultureInfo.InvariantCulture), + w.ToString("F1", CultureInfo.InvariantCulture)); + } + + /// + /// Returns a nicely formatted string for this vector. + /// + /// The float format. + /// The string. + public string ToString(string format) + { + return string.Format("({0}, {1}, {2}, {3})", + x.ToString(format, CultureInfo.InvariantCulture), + y.ToString(format, CultureInfo.InvariantCulture), + z.ToString(format, CultureInfo.InvariantCulture), + w.ToString(format, CultureInfo.InvariantCulture)); + } + #endregion + + #region Static + /// + /// Dot Product of two vectors. + /// + /// The left hand side vector. + /// The right hand side vector. + public static double Dot(ref Vector4d lhs, ref Vector4d rhs) + { + return lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z + lhs.w * rhs.w; + } + + /// + /// Performs a linear interpolation between two vectors. + /// + /// The vector to interpolate from. + /// The vector to interpolate to. + /// The time fraction. + /// The resulting vector. + public static void Lerp(ref Vector4d a, ref Vector4d b, double t, out Vector4d result) + { + result = new Vector4d(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t, a.w + (b.w - a.w) * t); + } + + /// + /// Multiplies two vectors component-wise. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static void Scale(ref Vector4d a, ref Vector4d b, out Vector4d result) + { + result = new Vector4d(a.x * b.x, a.y * b.y, a.z * b.z, a.w * b.w); + } + + /// + /// Normalizes a vector. + /// + /// The vector to normalize. + /// The resulting normalized vector. + public static void Normalize(ref Vector4d value, out Vector4d result) + { + double mag = value.Magnitude; + if (mag > Epsilon) + { + result = new Vector4d(value.x / mag, value.y / mag, value.z / mag, value.w / mag); + } + else + { + result = Vector4d.zero; + } + } + #endregion + #endregion + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector4i.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector4i.cs new file mode 100644 index 0000000..cc52459 --- /dev/null +++ b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector4i.cs @@ -0,0 +1,388 @@ +#region License +/* +MIT License + +Copyright(c) 2017-2018 Mattias Edlund + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +#endregion + +using System; +using System.Globalization; + +namespace MeshDecimator.Math +{ + /// + /// A 4D integer vector. + /// + public struct Vector4i : IEquatable + { + #region Static Read-Only + /// + /// The zero vector. + /// + public static readonly Vector4i zero = new Vector4i(0, 0, 0, 0); + #endregion + + #region Fields + /// + /// The x component. + /// + public int x; + /// + /// The y component. + /// + public int y; + /// + /// The z component. + /// + public int z; + /// + /// The w component. + /// + public int w; + #endregion + + #region Properties + /// + /// Gets the magnitude of this vector. + /// + public int Magnitude + { + get { return (int)System.Math.Sqrt(x * x + y * y + z * z + w * w); } + } + + /// + /// Gets the squared magnitude of this vector. + /// + public int MagnitudeSqr + { + get { return (x * x + y * y + z * z + w * w); } + } + + /// + /// Gets or sets a specific component by index in this vector. + /// + /// The component index. + public int this[int index] + { + get + { + switch (index) + { + case 0: + return x; + case 1: + return y; + case 2: + return z; + case 3: + return w; + default: + throw new IndexOutOfRangeException("Invalid Vector4i index!"); + } + } + set + { + switch (index) + { + case 0: + x = value; + break; + case 1: + y = value; + break; + case 2: + z = value; + break; + case 3: + w = value; + break; + default: + throw new IndexOutOfRangeException("Invalid Vector4i index!"); + } + } + } + #endregion + + #region Constructor + /// + /// Creates a new vector with one value for all components. + /// + /// The value. + public Vector4i(int value) + { + this.x = value; + this.y = value; + this.z = value; + this.w = value; + } + + /// + /// Creates a new vector. + /// + /// The x value. + /// The y value. + /// The z value. + /// The w value. + public Vector4i(int x, int y, int z, int w) + { + this.x = x; + this.y = y; + this.z = z; + this.w = w; + } + #endregion + + #region Operators + /// + /// Adds two vectors. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static Vector4i operator +(Vector4i a, Vector4i b) + { + return new Vector4i(a.x + b.x, a.y + b.y, a.z + b.z, a.w + b.w); + } + + /// + /// Subtracts two vectors. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static Vector4i operator -(Vector4i a, Vector4i b) + { + return new Vector4i(a.x - b.x, a.y - b.y, a.z - b.z, a.w - b.w); + } + + /// + /// Scales the vector uniformly. + /// + /// The vector. + /// The scaling value. + /// The resulting vector. + public static Vector4i operator *(Vector4i a, int d) + { + return new Vector4i(a.x * d, a.y * d, a.z * d, a.w * d); + } + + /// + /// Scales the vector uniformly. + /// + /// The scaling value. + /// The vector. + /// The resulting vector. + public static Vector4i operator *(int d, Vector4i a) + { + return new Vector4i(a.x * d, a.y * d, a.z * d, a.w * d); + } + + /// + /// Divides the vector with a float. + /// + /// The vector. + /// The dividing float value. + /// The resulting vector. + public static Vector4i operator /(Vector4i a, int d) + { + return new Vector4i(a.x / d, a.y / d, a.z / d, a.w / d); + } + + /// + /// Subtracts the vector from a zero vector. + /// + /// The vector. + /// The resulting vector. + public static Vector4i operator -(Vector4i a) + { + return new Vector4i(-a.x, -a.y, -a.z, -a.w); + } + + /// + /// Returns if two vectors equals eachother. + /// + /// The left hand side vector. + /// The right hand side vector. + /// If equals. + public static bool operator ==(Vector4i lhs, Vector4i rhs) + { + return (lhs.x == rhs.x && lhs.y == rhs.y && lhs.z == rhs.z && lhs.w == rhs.w); + } + + /// + /// Returns if two vectors don't equal eachother. + /// + /// The left hand side vector. + /// The right hand side vector. + /// If not equals. + public static bool operator !=(Vector4i lhs, Vector4i rhs) + { + return (lhs.x != rhs.x || lhs.y != rhs.y || lhs.z != rhs.z || lhs.w != rhs.w); + } + + /// + /// Explicitly converts from a single-precision vector into an integer vector. + /// + /// The single-precision vector. + public static explicit operator Vector4i(Vector4 v) + { + return new Vector4i((int)v.x, (int)v.y, (int)v.z, (int)v.w); + } + + /// + /// Explicitly converts from a double-precision vector into an integer vector. + /// + /// The double-precision vector. + public static explicit operator Vector4i(Vector4d v) + { + return new Vector4i((int)v.x, (int)v.y, (int)v.z, (int)v.w); + } + #endregion + + #region Public Methods + #region Instance + /// + /// Set x, y and z components of an existing vector. + /// + /// The x value. + /// The y value. + /// The z value. + /// The w value. + public void Set(int x, int y, int z, int w) + { + this.x = x; + this.y = y; + this.z = z; + this.w = w; + } + + /// + /// Multiplies with another vector component-wise. + /// + /// The vector to multiply with. + public void Scale(ref Vector4i scale) + { + x *= scale.x; + y *= scale.y; + z *= scale.z; + w *= scale.w; + } + + /// + /// Clamps this vector between a specific range. + /// + /// The minimum component value. + /// The maximum component value. + public void Clamp(int min, int max) + { + if (x < min) x = min; + else if (x > max) x = max; + + if (y < min) y = min; + else if (y > max) y = max; + + if (z < min) z = min; + else if (z > max) z = max; + + if (w < min) w = min; + else if (w > max) w = max; + } + #endregion + + #region Object + /// + /// Returns a hash code for this vector. + /// + /// The hash code. + public override int GetHashCode() + { + return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2 ^ w.GetHashCode() >> 1; + } + + /// + /// Returns if this vector is equal to another one. + /// + /// The other vector to compare to. + /// If equals. + public override bool Equals(object other) + { + if (!(other is Vector4i)) + { + return false; + } + Vector4i vector = (Vector4i)other; + return (x == vector.x && y == vector.y && z == vector.z && w == vector.w); + } + + /// + /// Returns if this vector is equal to another one. + /// + /// The other vector to compare to. + /// If equals. + public bool Equals(Vector4i other) + { + return (x == other.x && y == other.y && z == other.z && w == other.w); + } + + /// + /// Returns a nicely formatted string for this vector. + /// + /// The string. + public override string ToString() + { + return string.Format("({0}, {1}, {2}, {3})", + x.ToString(CultureInfo.InvariantCulture), + y.ToString(CultureInfo.InvariantCulture), + z.ToString(CultureInfo.InvariantCulture), + w.ToString(CultureInfo.InvariantCulture)); + } + + /// + /// Returns a nicely formatted string for this vector. + /// + /// The integer format. + /// The string. + public string ToString(string format) + { + return string.Format("({0}, {1}, {2}, {3})", + x.ToString(format, CultureInfo.InvariantCulture), + y.ToString(format, CultureInfo.InvariantCulture), + z.ToString(format, CultureInfo.InvariantCulture), + w.ToString(format, CultureInfo.InvariantCulture)); + } + #endregion + + #region Static + /// + /// Multiplies two vectors component-wise. + /// + /// The first vector. + /// The second vector. + /// The resulting vector. + public static void Scale(ref Vector4i a, ref Vector4i b, out Vector4i result) + { + result = new Vector4i(a.x * b.x, a.y * b.y, a.z * b.z, a.w * b.w); + } + #endregion + #endregion + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Mesh.cs b/LightlessSync/ThirdParty/MeshDecimator/Mesh.cs new file mode 100644 index 0000000..2e38821 --- /dev/null +++ b/LightlessSync/ThirdParty/MeshDecimator/Mesh.cs @@ -0,0 +1,955 @@ +#region License +/* +MIT License + +Copyright(c) 2017-2018 Mattias Edlund + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +#endregion + +using System; +using System.Collections.Generic; +using MeshDecimator.Math; + +namespace MeshDecimator +{ + /// + /// A mesh. + /// + public sealed class Mesh + { + #region Consts + /// + /// The count of supported UV channels. + /// + public const int UVChannelCount = 4; + #endregion + + #region Fields + private Vector3d[] vertices = null; + private int[][] indices = null; + private Vector3[] normals = null; + private Vector4[] tangents = null; + private Vector2[][] uvs2D = null; + private Vector3[][] uvs3D = null; + private Vector4[][] uvs4D = null; + private Vector4[] colors = null; + private BoneWeight[] boneWeights = null; + + private static readonly int[] emptyIndices = new int[0]; + #endregion + + #region Properties + /// + /// Gets the count of vertices of this mesh. + /// + public int VertexCount + { + get { return vertices.Length; } + } + + /// + /// Gets or sets the count of submeshes in this mesh. + /// + public int SubMeshCount + { + get { return indices.Length; } + set + { + if (value <= 0) + throw new ArgumentOutOfRangeException("value"); + + int[][] newIndices = new int[value][]; + Array.Copy(indices, 0, newIndices, 0, MathHelper.Min(indices.Length, newIndices.Length)); + indices = newIndices; + } + } + + /// + /// Gets the total count of triangles in this mesh. + /// + public int TriangleCount + { + get + { + int triangleCount = 0; + for (int i = 0; i < indices.Length; i++) + { + if (indices[i] != null) + { + triangleCount += indices[i].Length / 3; + } + } + return triangleCount; + } + } + + /// + /// Gets or sets the vertices for this mesh. Note that this resets all other vertex attributes. + /// + public Vector3d[] Vertices + { + get { return vertices; } + set + { + if (value == null) + throw new ArgumentNullException("value"); + + vertices = value; + ClearVertexAttributes(); + } + } + + /// + /// Gets or sets the combined indices for this mesh. Once set, the sub-mesh count gets set to 1. + /// + public int[] Indices + { + get + { + if (indices.Length == 1) + { + return indices[0] ?? emptyIndices; + } + else + { + List indexList = new List(TriangleCount * 3); + for (int i = 0; i < indices.Length; i++) + { + if (indices[i] != null) + { + indexList.AddRange(indices[i]); + } + } + return indexList.ToArray(); + } + } + set + { + if (value == null) + throw new ArgumentNullException("value"); + else if ((value.Length % 3) != 0) + throw new ArgumentException("The index count must be multiple by 3.", "value"); + + SubMeshCount = 1; + SetIndices(0, value); + } + } + + /// + /// Gets or sets the normals for this mesh. + /// + public Vector3[] Normals + { + get { return normals; } + set + { + if (value != null && value.Length != vertices.Length) + throw new ArgumentException(string.Format("The vertex normals must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length)); + + normals = value; + } + } + + /// + /// Gets or sets the tangents for this mesh. + /// + public Vector4[] Tangents + { + get { return tangents; } + set + { + if (value != null && value.Length != vertices.Length) + throw new ArgumentException(string.Format("The vertex tangents must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length)); + + tangents = value; + } + } + + /// + /// Gets or sets the first UV set for this mesh. + /// + public Vector2[] UV1 + { + get { return GetUVs2D(0); } + set { SetUVs(0, value); } + } + + /// + /// Gets or sets the second UV set for this mesh. + /// + public Vector2[] UV2 + { + get { return GetUVs2D(1); } + set { SetUVs(1, value); } + } + + /// + /// Gets or sets the third UV set for this mesh. + /// + public Vector2[] UV3 + { + get { return GetUVs2D(2); } + set { SetUVs(2, value); } + } + + /// + /// Gets or sets the fourth UV set for this mesh. + /// + public Vector2[] UV4 + { + get { return GetUVs2D(3); } + set { SetUVs(3, value); } + } + + /// + /// Gets or sets the vertex colors for this mesh. + /// + public Vector4[] Colors + { + get { return colors; } + set + { + if (value != null && value.Length != vertices.Length) + throw new ArgumentException(string.Format("The vertex colors must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length)); + + colors = value; + } + } + + /// + /// Gets or sets the vertex bone weights for this mesh. + /// + public BoneWeight[] BoneWeights + { + get { return boneWeights; } + set + { + if (value != null && value.Length != vertices.Length) + throw new ArgumentException(string.Format("The vertex bone weights must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length)); + + boneWeights = value; + } + } + #endregion + + #region Constructor + /// + /// Creates a new mesh. + /// + /// The mesh vertices. + /// The mesh indices. + public Mesh(Vector3d[] vertices, int[] indices) + { + if (vertices == null) + throw new ArgumentNullException("vertices"); + else if (indices == null) + throw new ArgumentNullException("indices"); + else if ((indices.Length % 3) != 0) + throw new ArgumentException("The index count must be multiple by 3.", "indices"); + + this.vertices = vertices; + this.indices = new int[1][]; + this.indices[0] = indices; + } + + /// + /// Creates a new mesh. + /// + /// The mesh vertices. + /// The mesh indices. + public Mesh(Vector3d[] vertices, int[][] indices) + { + if (vertices == null) + throw new ArgumentNullException("vertices"); + else if (indices == null) + throw new ArgumentNullException("indices"); + + for (int i = 0; i < indices.Length; i++) + { + if (indices[i] != null && (indices[i].Length % 3) != 0) + throw new ArgumentException(string.Format("The index count must be multiple by 3 at sub-mesh index {0}.", i), "indices"); + } + + this.vertices = vertices; + this.indices = indices; + } + #endregion + + #region Private Methods + private void ClearVertexAttributes() + { + normals = null; + tangents = null; + uvs2D = null; + uvs3D = null; + uvs4D = null; + colors = null; + boneWeights = null; + } + #endregion + + #region Public Methods + #region Recalculate Normals + /// + /// Recalculates the normals for this mesh smoothly. + /// + public void RecalculateNormals() + { + int vertexCount = vertices.Length; + Vector3[] normals = new Vector3[vertexCount]; + + int subMeshCount = this.indices.Length; + for (int subMeshIndex = 0; subMeshIndex < subMeshCount; subMeshIndex++) + { + int[] indices = this.indices[subMeshIndex]; + if (indices == null) + continue; + + int indexCount = indices.Length; + for (int i = 0; i < indexCount; i += 3) + { + int i0 = indices[i]; + int i1 = indices[i + 1]; + int i2 = indices[i + 2]; + + var v0 = (Vector3)vertices[i0]; + var v1 = (Vector3)vertices[i1]; + var v2 = (Vector3)vertices[i2]; + + var nx = v1 - v0; + var ny = v2 - v0; + Vector3 normal; + Vector3.Cross(ref nx, ref ny, out normal); + normal.Normalize(); + + normals[i0] += normal; + normals[i1] += normal; + normals[i2] += normal; + } + } + + for (int i = 0; i < vertexCount; i++) + { + normals[i].Normalize(); + } + + this.normals = normals; + } + #endregion + + #region Recalculate Tangents + /// + /// Recalculates the tangents for this mesh. + /// + public void RecalculateTangents() + { + // Make sure we have the normals first + if (normals == null) + return; + + // Also make sure that we have the first UV set + bool uvIs2D = (uvs2D != null && uvs2D[0] != null); + bool uvIs3D = (uvs3D != null && uvs3D[0] != null); + bool uvIs4D = (uvs4D != null && uvs4D[0] != null); + if (!uvIs2D && !uvIs3D && !uvIs4D) + return; + + int vertexCount = vertices.Length; + + var tangents = new Vector4[vertexCount]; + var tan1 = new Vector3[vertexCount]; + var tan2 = new Vector3[vertexCount]; + + Vector2[] uv2D = (uvIs2D ? uvs2D[0] : null); + Vector3[] uv3D = (uvIs3D ? uvs3D[0] : null); + Vector4[] uv4D = (uvIs4D ? uvs4D[0] : null); + + int subMeshCount = this.indices.Length; + for (int subMeshIndex = 0; subMeshIndex < subMeshCount; subMeshIndex++) + { + int[] indices = this.indices[subMeshIndex]; + if (indices == null) + continue; + + int indexCount = indices.Length; + for (int i = 0; i < indexCount; i += 3) + { + int i0 = indices[i]; + int i1 = indices[i + 1]; + int i2 = indices[i + 2]; + + var v0 = vertices[i0]; + var v1 = vertices[i1]; + var v2 = vertices[i2]; + + float s1, s2, t1, t2; + if (uvIs2D) + { + var w0 = uv2D[i0]; + var w1 = uv2D[i1]; + var w2 = uv2D[i2]; + s1 = w1.x - w0.x; + s2 = w2.x - w0.x; + t1 = w1.y - w0.y; + t2 = w2.y - w0.y; + } + else if (uvIs3D) + { + var w0 = uv3D[i0]; + var w1 = uv3D[i1]; + var w2 = uv3D[i2]; + s1 = w1.x - w0.x; + s2 = w2.x - w0.x; + t1 = w1.y - w0.y; + t2 = w2.y - w0.y; + } + else + { + var w0 = uv4D[i0]; + var w1 = uv4D[i1]; + var w2 = uv4D[i2]; + s1 = w1.x - w0.x; + s2 = w2.x - w0.x; + t1 = w1.y - w0.y; + t2 = w2.y - w0.y; + } + + + float x1 = (float)(v1.x - v0.x); + float x2 = (float)(v2.x - v0.x); + float y1 = (float)(v1.y - v0.y); + float y2 = (float)(v2.y - v0.y); + float z1 = (float)(v1.z - v0.z); + float z2 = (float)(v2.z - v0.z); + float r = 1f / (s1 * t2 - s2 * t1); + + var sdir = new Vector3((t2 * x1 - t1 * x2) * r, (t2 * y1 - t1 * y2) * r, (t2 * z1 - t1 * z2) * r); + var tdir = new Vector3((s1 * x2 - s2 * x1) * r, (s1 * y2 - s2 * y1) * r, (s1 * z2 - s2 * z1) * r); + + tan1[i0] += sdir; + tan1[i1] += sdir; + tan1[i2] += sdir; + tan2[i0] += tdir; + tan2[i1] += tdir; + tan2[i2] += tdir; + } + } + + for (int i = 0; i < vertexCount; i++) + { + var n = normals[i]; + var t = tan1[i]; + + var tmp = (t - n * Vector3.Dot(ref n, ref t)); + tmp.Normalize(); + + Vector3 c; + Vector3.Cross(ref n, ref t, out c); + float dot = Vector3.Dot(ref c, ref tan2[i]); + float w = (dot < 0f ? -1f : 1f); + tangents[i] = new Vector4(tmp.x, tmp.y, tmp.z, w); + } + + this.tangents = tangents; + } + #endregion + + #region Triangles + /// + /// Returns the count of triangles for a specific sub-mesh in this mesh. + /// + /// The sub-mesh index. + /// The triangle count. + public int GetTriangleCount(int subMeshIndex) + { + if (subMeshIndex < 0 || subMeshIndex >= indices.Length) + throw new IndexOutOfRangeException(); + + return indices[subMeshIndex].Length / 3; + } + + /// + /// Returns the triangle indices of a specific sub-mesh in this mesh. + /// + /// The sub-mesh index. + /// The triangle indices. + public int[] GetIndices(int subMeshIndex) + { + if (subMeshIndex < 0 || subMeshIndex >= indices.Length) + throw new IndexOutOfRangeException(); + + return indices[subMeshIndex] ?? emptyIndices; + } + + /// + /// Returns the triangle indices for all sub-meshes in this mesh. + /// + /// The sub-mesh triangle indices. + public int[][] GetSubMeshIndices() + { + var subMeshIndices = new int[indices.Length][]; + for (int subMeshIndex = 0; subMeshIndex < indices.Length; subMeshIndex++) + { + subMeshIndices[subMeshIndex] = indices[subMeshIndex] ?? emptyIndices; + } + return subMeshIndices; + } + + /// + /// Sets the triangle indices of a specific sub-mesh in this mesh. + /// + /// The sub-mesh index. + /// The triangle indices. + public void SetIndices(int subMeshIndex, int[] indices) + { + if (subMeshIndex < 0 || subMeshIndex >= this.indices.Length) + throw new IndexOutOfRangeException(); + else if (indices == null) + throw new ArgumentNullException("indices"); + else if ((indices.Length % 3) != 0) + throw new ArgumentException("The index count must be multiple by 3.", "indices"); + + this.indices[subMeshIndex] = indices; + } + #endregion + + #region UV Sets + #region Getting + /// + /// Returns the UV dimension for a specific channel. + /// + /// + /// The UV dimension count. + public int GetUVDimension(int channel) + { + if (channel < 0 || channel >= UVChannelCount) + throw new ArgumentOutOfRangeException("channel"); + + if (uvs2D != null && uvs2D[channel] != null) + { + return 2; + } + else if (uvs3D != null && uvs3D[channel] != null) + { + return 3; + } + else if (uvs4D != null && uvs4D[channel] != null) + { + return 4; + } + else + { + return 0; + } + } + + /// + /// Returns the UVs (2D) from a specific channel. + /// + /// The channel index. + /// The UVs. + public Vector2[] GetUVs2D(int channel) + { + if (channel < 0 || channel >= UVChannelCount) + throw new ArgumentOutOfRangeException("channel"); + + if (uvs2D != null && uvs2D[channel] != null) + { + return uvs2D[channel]; + } + else + { + return null; + } + } + + /// + /// Returns the UVs (3D) from a specific channel. + /// + /// The channel index. + /// The UVs. + public Vector3[] GetUVs3D(int channel) + { + if (channel < 0 || channel >= UVChannelCount) + throw new ArgumentOutOfRangeException("channel"); + + if (uvs3D != null && uvs3D[channel] != null) + { + return uvs3D[channel]; + } + else + { + return null; + } + } + + /// + /// Returns the UVs (4D) from a specific channel. + /// + /// The channel index. + /// The UVs. + public Vector4[] GetUVs4D(int channel) + { + if (channel < 0 || channel >= UVChannelCount) + throw new ArgumentOutOfRangeException("channel"); + + if (uvs4D != null && uvs4D[channel] != null) + { + return uvs4D[channel]; + } + else + { + return null; + } + } + + /// + /// Returns the UVs (2D) from a specific channel. + /// + /// The channel index. + /// The UVs. + public void GetUVs(int channel, List uvs) + { + if (channel < 0 || channel >= UVChannelCount) + throw new ArgumentOutOfRangeException("channel"); + else if (uvs == null) + throw new ArgumentNullException("uvs"); + + uvs.Clear(); + if (uvs2D != null && uvs2D[channel] != null) + { + var uvData = uvs2D[channel]; + if (uvData != null) + { + uvs.AddRange(uvData); + } + } + } + + /// + /// Returns the UVs (3D) from a specific channel. + /// + /// The channel index. + /// The UVs. + public void GetUVs(int channel, List uvs) + { + if (channel < 0 || channel >= UVChannelCount) + throw new ArgumentOutOfRangeException("channel"); + else if (uvs == null) + throw new ArgumentNullException("uvs"); + + uvs.Clear(); + if (uvs3D != null && uvs3D[channel] != null) + { + var uvData = uvs3D[channel]; + if (uvData != null) + { + uvs.AddRange(uvData); + } + } + } + + /// + /// Returns the UVs (4D) from a specific channel. + /// + /// The channel index. + /// The UVs. + public void GetUVs(int channel, List uvs) + { + if (channel < 0 || channel >= UVChannelCount) + throw new ArgumentOutOfRangeException("channel"); + else if (uvs == null) + throw new ArgumentNullException("uvs"); + + uvs.Clear(); + if (uvs4D != null && uvs4D[channel] != null) + { + var uvData = uvs4D[channel]; + if (uvData != null) + { + uvs.AddRange(uvData); + } + } + } + #endregion + + #region Setting + /// + /// Sets the UVs (2D) for a specific channel. + /// + /// The channel index. + /// The UVs. + public void SetUVs(int channel, Vector2[] uvs) + { + if (channel < 0 || channel >= UVChannelCount) + throw new ArgumentOutOfRangeException("channel"); + + if (uvs != null && uvs.Length > 0) + { + if (uvs.Length != vertices.Length) + throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvs.Length, vertices.Length)); + + if (uvs2D == null) + uvs2D = new Vector2[UVChannelCount][]; + + int uvCount = uvs.Length; + var uvSet = new Vector2[uvCount]; + uvs2D[channel] = uvSet; + uvs.CopyTo(uvSet, 0); + } + else + { + if (uvs2D != null) + { + uvs2D[channel] = null; + } + } + + if (uvs3D != null) + { + uvs3D[channel] = null; + } + if (uvs4D != null) + { + uvs4D[channel] = null; + } + } + + /// + /// Sets the UVs (3D) for a specific channel. + /// + /// The channel index. + /// The UVs. + public void SetUVs(int channel, Vector3[] uvs) + { + if (channel < 0 || channel >= UVChannelCount) + throw new ArgumentOutOfRangeException("channel"); + + if (uvs != null && uvs.Length > 0) + { + int uvCount = uvs.Length; + if (uvCount != vertices.Length) + throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvCount, vertices.Length), "uvs"); + + if (uvs3D == null) + uvs3D = new Vector3[UVChannelCount][]; + + var uvSet = new Vector3[uvCount]; + uvs3D[channel] = uvSet; + uvs.CopyTo(uvSet, 0); + } + else + { + if (uvs3D != null) + { + uvs3D[channel] = null; + } + } + + if (uvs2D != null) + { + uvs2D[channel] = null; + } + if (uvs4D != null) + { + uvs4D[channel] = null; + } + } + + /// + /// Sets the UVs (4D) for a specific channel. + /// + /// The channel index. + /// The UVs. + public void SetUVs(int channel, Vector4[] uvs) + { + if (channel < 0 || channel >= UVChannelCount) + throw new ArgumentOutOfRangeException("channel"); + + if (uvs != null && uvs.Length > 0) + { + int uvCount = uvs.Length; + if (uvCount != vertices.Length) + throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvCount, vertices.Length), "uvs"); + + if (uvs4D == null) + uvs4D = new Vector4[UVChannelCount][]; + + var uvSet = new Vector4[uvCount]; + uvs4D[channel] = uvSet; + uvs.CopyTo(uvSet, 0); + } + else + { + if (uvs4D != null) + { + uvs4D[channel] = null; + } + } + + if (uvs2D != null) + { + uvs2D[channel] = null; + } + if (uvs3D != null) + { + uvs3D[channel] = null; + } + } + + /// + /// Sets the UVs (2D) for a specific channel. + /// + /// The channel index. + /// The UVs. + public void SetUVs(int channel, List uvs) + { + if (channel < 0 || channel >= UVChannelCount) + throw new ArgumentOutOfRangeException("channel"); + + if (uvs != null && uvs.Count > 0) + { + int uvCount = uvs.Count; + if (uvCount != vertices.Length) + throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvCount, vertices.Length), "uvs"); + + if (uvs2D == null) + uvs2D = new Vector2[UVChannelCount][]; + + var uvSet = new Vector2[uvCount]; + uvs2D[channel] = uvSet; + uvs.CopyTo(uvSet, 0); + } + else + { + if (uvs2D != null) + { + uvs2D[channel] = null; + } + } + + if (uvs3D != null) + { + uvs3D[channel] = null; + } + if (uvs4D != null) + { + uvs4D[channel] = null; + } + } + + /// + /// Sets the UVs (3D) for a specific channel. + /// + /// The channel index. + /// The UVs. + public void SetUVs(int channel, List uvs) + { + if (channel < 0 || channel >= UVChannelCount) + throw new ArgumentOutOfRangeException("channel"); + + if (uvs != null && uvs.Count > 0) + { + int uvCount = uvs.Count; + if (uvCount != vertices.Length) + throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvCount, vertices.Length), "uvs"); + + if (uvs3D == null) + uvs3D = new Vector3[UVChannelCount][]; + + var uvSet = new Vector3[uvCount]; + uvs3D[channel] = uvSet; + uvs.CopyTo(uvSet, 0); + } + else + { + if (uvs3D != null) + { + uvs3D[channel] = null; + } + } + + if (uvs2D != null) + { + uvs2D[channel] = null; + } + if (uvs4D != null) + { + uvs4D[channel] = null; + } + } + + /// + /// Sets the UVs (4D) for a specific channel. + /// + /// The channel index. + /// The UVs. + public void SetUVs(int channel, List uvs) + { + if (channel < 0 || channel >= UVChannelCount) + throw new ArgumentOutOfRangeException("channel"); + + if (uvs != null && uvs.Count > 0) + { + int uvCount = uvs.Count; + if (uvCount != vertices.Length) + throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvCount, vertices.Length), "uvs"); + + if (uvs4D == null) + uvs4D = new Vector4[UVChannelCount][]; + + var uvSet = new Vector4[uvCount]; + uvs4D[channel] = uvSet; + uvs.CopyTo(uvSet, 0); + } + else + { + if (uvs4D != null) + { + uvs4D[channel] = null; + } + } + + if (uvs2D != null) + { + uvs2D[channel] = null; + } + if (uvs3D != null) + { + uvs3D[channel] = null; + } + } + #endregion + #endregion + + #region To String + /// + /// Returns the text-representation of this mesh. + /// + /// The text-representation. + public override string ToString() + { + return string.Format("Vertices: {0}", vertices.Length); + } + #endregion + #endregion + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/MeshDecimation.cs b/LightlessSync/ThirdParty/MeshDecimator/MeshDecimation.cs new file mode 100644 index 0000000..cb13fe8 --- /dev/null +++ b/LightlessSync/ThirdParty/MeshDecimator/MeshDecimation.cs @@ -0,0 +1,180 @@ +#region License +/* +MIT License + +Copyright(c) 2017-2018 Mattias Edlund + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +#endregion + +using System; +using MeshDecimator.Algorithms; + +namespace MeshDecimator +{ + #region Algorithm + /// + /// The decimation algorithms. + /// + public enum Algorithm + { + /// + /// The default algorithm. + /// + Default, + /// + /// The fast quadric mesh simplification algorithm. + /// + FastQuadricMesh + } + #endregion + + /// + /// The mesh decimation API. + /// + public static class MeshDecimation + { + #region Public Methods + #region Create Algorithm + /// + /// Creates a specific decimation algorithm. + /// + /// The desired algorithm. + /// The decimation algorithm. + public static DecimationAlgorithm CreateAlgorithm(Algorithm algorithm) + { + DecimationAlgorithm alg = null; + + switch (algorithm) + { + case Algorithm.Default: + case Algorithm.FastQuadricMesh: + alg = new FastQuadricMeshSimplification(); + break; + default: + throw new ArgumentException("The specified algorithm is not supported.", "algorithm"); + } + + return alg; + } + #endregion + + #region Decimate Mesh + /// + /// Decimates a mesh. + /// + /// The mesh to decimate. + /// The target triangle count. + /// The decimated mesh. + public static Mesh DecimateMesh(Mesh mesh, int targetTriangleCount) + { + return DecimateMesh(Algorithm.Default, mesh, targetTriangleCount); + } + + /// + /// Decimates a mesh. + /// + /// The desired algorithm. + /// The mesh to decimate. + /// The target triangle count. + /// The decimated mesh. + public static Mesh DecimateMesh(Algorithm algorithm, Mesh mesh, int targetTriangleCount) + { + if (mesh == null) + throw new ArgumentNullException("mesh"); + + var decimationAlgorithm = CreateAlgorithm(algorithm); + return DecimateMesh(decimationAlgorithm, mesh, targetTriangleCount); + } + + /// + /// Decimates a mesh. + /// + /// The decimation algorithm. + /// The mesh to decimate. + /// The target triangle count. + /// The decimated mesh. + public static Mesh DecimateMesh(DecimationAlgorithm algorithm, Mesh mesh, int targetTriangleCount) + { + if (algorithm == null) + throw new ArgumentNullException("algorithm"); + else if (mesh == null) + throw new ArgumentNullException("mesh"); + + int currentTriangleCount = mesh.TriangleCount; + if (targetTriangleCount > currentTriangleCount) + targetTriangleCount = currentTriangleCount; + else if (targetTriangleCount < 0) + targetTriangleCount = 0; + + algorithm.Initialize(mesh); + algorithm.DecimateMesh(targetTriangleCount); + return algorithm.ToMesh(); + } + #endregion + + #region Decimate Mesh Lossless + /// + /// Decimates a mesh without losing any quality. + /// + /// The mesh to decimate. + /// The decimated mesh. + public static Mesh DecimateMeshLossless(Mesh mesh) + { + return DecimateMeshLossless(Algorithm.Default, mesh); + } + + /// + /// Decimates a mesh without losing any quality. + /// + /// The desired algorithm. + /// The mesh to decimate. + /// The decimated mesh. + public static Mesh DecimateMeshLossless(Algorithm algorithm, Mesh mesh) + { + if (mesh == null) + throw new ArgumentNullException("mesh"); + + var decimationAlgorithm = CreateAlgorithm(algorithm); + return DecimateMeshLossless(decimationAlgorithm, mesh); + } + + /// + /// Decimates a mesh without losing any quality. + /// + /// The decimation algorithm. + /// The mesh to decimate. + /// The decimated mesh. + public static Mesh DecimateMeshLossless(DecimationAlgorithm algorithm, Mesh mesh) + { + if (algorithm == null) + throw new ArgumentNullException("algorithm"); + else if (mesh == null) + throw new ArgumentNullException("mesh"); + + int currentTriangleCount = mesh.TriangleCount; + algorithm.Initialize(mesh); + algorithm.DecimateMeshLossless(); + return algorithm.ToMesh(); + } + #endregion + #endregion + } +} \ No newline at end of file diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index 79fbc88..43f0c0b 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -52,6 +52,10 @@ public class CompactUi : WindowMediatorSubscriberBase private readonly LightlessConfigService _configService; private readonly LightlessMediator _lightlessMediator; private readonly PairLedger _pairLedger; + private readonly ConcurrentDictionary> _currentDownloads = new(); + private readonly DrawEntityFactory _drawEntityFactory; + private readonly FileUploadManager _fileTransferManager; + private readonly PlayerPerformanceConfigService _playerPerformanceConfig; private readonly PairUiService _pairUiService; private readonly PlayerPerformanceConfigService _playerPerformanceConfig; private readonly ServerConfigurationManager _serverManager; @@ -991,6 +995,7 @@ public class CompactUi : WindowMediatorSubscriberBase VisiblePairSortMode.VramUsage => SortVisibleByMetric(entryList, e => e.LastAppliedApproximateVramBytes), VisiblePairSortMode.EffectiveVramUsage => SortVisibleByMetric(entryList, e => e.LastAppliedApproximateEffectiveVramBytes), VisiblePairSortMode.TriangleCount => SortVisibleByMetric(entryList, e => e.LastAppliedDataTris), + VisiblePairSortMode.EffectiveTriangleCount => SortVisibleByMetric(entryList, e => e.LastAppliedApproximateEffectiveTris), VisiblePairSortMode.Alphabetical => [.. entryList.OrderBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase)], VisiblePairSortMode.PreferredDirectPairs => SortVisibleByPreferred(entryList), _ => SortEntries(entryList), diff --git a/LightlessSync/UI/Components/DrawFolderTag.cs b/LightlessSync/UI/Components/DrawFolderTag.cs index b91617a..0870a0d 100644 --- a/LightlessSync/UI/Components/DrawFolderTag.cs +++ b/LightlessSync/UI/Components/DrawFolderTag.cs @@ -326,6 +326,7 @@ public class DrawFolderTag : DrawFolderBase VisiblePairSortMode.VramUsage => "VRAM usage (descending)", VisiblePairSortMode.EffectiveVramUsage => "Effective VRAM usage (descending)", VisiblePairSortMode.TriangleCount => "Triangle count (descending)", + VisiblePairSortMode.EffectiveTriangleCount => "Effective triangle count (descending)", VisiblePairSortMode.PreferredDirectPairs => "Preferred permissions & Direct pairs", _ => "Default", }; diff --git a/LightlessSync/UI/Components/DrawUserPair.cs b/LightlessSync/UI/Components/DrawUserPair.cs index c8725e2..5524226 100644 --- a/LightlessSync/UI/Components/DrawUserPair.cs +++ b/LightlessSync/UI/Components/DrawUserPair.cs @@ -429,6 +429,7 @@ public class DrawUserPair _pair.LastAppliedApproximateVRAMBytes, _pair.LastAppliedApproximateEffectiveVRAMBytes, _pair.LastAppliedDataTris, + _pair.LastAppliedApproximateEffectiveTris, _pair.IsPaired, groupDisplays is null ? ImmutableArray.Empty : ImmutableArray.CreateRange(groupDisplays)); @@ -444,6 +445,8 @@ public class DrawUserPair private static string BuildTooltip(in TooltipSnapshot snapshot) { var builder = new StringBuilder(256); + static string FormatTriangles(long count) => + count > 1000 ? (count / 1000d).ToString("0.0'k'") : count.ToString(); if (snapshot.IsPaused) { @@ -510,9 +513,13 @@ public class DrawUserPair { builder.Append(Environment.NewLine); builder.Append("Approx. Triangle Count (excl. Vanilla): "); - builder.Append(snapshot.LastAppliedDataTris > 1000 - ? (snapshot.LastAppliedDataTris / 1000d).ToString("0.0'k'") - : snapshot.LastAppliedDataTris); + builder.Append(FormatTriangles(snapshot.LastAppliedDataTris)); + if (snapshot.LastAppliedApproximateEffectiveTris >= 0) + { + builder.Append(" (Effective: "); + builder.Append(FormatTriangles(snapshot.LastAppliedApproximateEffectiveTris)); + builder.Append(')'); + } } } @@ -544,11 +551,12 @@ public class DrawUserPair long LastAppliedApproximateVRAMBytes, long LastAppliedApproximateEffectiveVRAMBytes, long LastAppliedDataTris, + long LastAppliedApproximateEffectiveTris, bool IsPaired, ImmutableArray GroupDisplays) { public static TooltipSnapshot Empty { get; } = - new(false, false, false, IndividualPairStatus.None, string.Empty, string.Empty, -1, -1, -1, -1, false, ImmutableArray.Empty); + new(false, false, false, IndividualPairStatus.None, string.Empty, string.Empty, -1, -1, -1, -1, -1, false, ImmutableArray.Empty); } private void DrawPairedClientMenu() diff --git a/LightlessSync/UI/DataAnalysisUi.cs b/LightlessSync/UI/DataAnalysisUi.cs index a3061a7..e0bfcb1 100644 --- a/LightlessSync/UI/DataAnalysisUi.cs +++ b/LightlessSync/UI/DataAnalysisUi.cs @@ -11,6 +11,7 @@ using LightlessSync.LightlessConfiguration; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Services.TextureCompression; +using LightlessSync.UI.Models; using LightlessSync.Utils; using Microsoft.Extensions.Logging; using OtterTex; @@ -34,12 +35,15 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private const float TextureDetailSplitterWidth = 12f; private const float TextureDetailSplitterCollapsedWidth = 18f; private const float SelectedFilePanelLogicalHeight = 90f; + private const float TextureHoverPreviewDelaySeconds = 1.75f; + private const float TextureHoverPreviewSize = 350f; private static readonly Vector4 SelectedTextureRowTextColor = new(0f, 0f, 0f, 1f); private readonly CharacterAnalyzer _characterAnalyzer; private readonly Progress _conversionProgress = new(); private readonly IpcManager _ipcManager; private readonly UiSharedService _uiSharedService; + private readonly LightlessConfigService _configService; private readonly PlayerPerformanceConfigService _playerPerformanceConfig; private readonly TransientResourceManager _transientResourceManager; private readonly TransientConfigService _transientConfigService; @@ -77,6 +81,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private string _selectedJobEntry = string.Empty; private string _filterGamePath = string.Empty; private string _filterFilePath = string.Empty; + private string _textureHoverKey = string.Empty; private int _conversionCurrentFileProgress = 0; private int _conversionTotalJobs; @@ -87,6 +92,11 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private bool _textureRowsDirty = true; private bool _textureDetailCollapsed = false; private bool _conversionFailed; + private double _textureHoverStartTime = 0; +#if DEBUG + private bool _debugCompressionModalOpen = false; + private TextureConversionProgress? _debugConversionProgress; +#endif private bool _showAlreadyAddedTransients = false; private bool _acknowledgeReview = false; @@ -98,10 +108,12 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private TextureUsageCategory? _textureCategoryFilter = null; private TextureMapKind? _textureMapFilter = null; private TextureCompressionTarget? _textureTargetFilter = null; + private TextureFormatSortMode _textureFormatSortMode = TextureFormatSortMode.None; public DataAnalysisUi(ILogger logger, LightlessMediator mediator, CharacterAnalyzer characterAnalyzer, IpcManager ipcManager, PerformanceCollectorService performanceCollectorService, UiSharedService uiSharedService, + LightlessConfigService configService, PlayerPerformanceConfigService playerPerformanceConfig, TransientResourceManager transientResourceManager, TransientConfigService transientConfigService, TextureCompressionService textureCompressionService, TextureMetadataHelper textureMetadataHelper) @@ -110,6 +122,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase _characterAnalyzer = characterAnalyzer; _ipcManager = ipcManager; _uiSharedService = uiSharedService; + _configService = configService; _playerPerformanceConfig = playerPerformanceConfig; _transientResourceManager = transientResourceManager; _transientConfigService = transientConfigService; @@ -135,21 +148,30 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private void HandleConversionModal() { - if (_conversionTask == null) + bool hasConversion = _conversionTask != null; +#if DEBUG + bool showDebug = _debugCompressionModalOpen && !hasConversion; +#else + const bool showDebug = false; +#endif + if (!hasConversion && !showDebug) { return; } - if (_conversionTask.IsCompleted) + if (hasConversion && _conversionTask!.IsCompleted) { ResetConversionModalState(); - return; + if (!showDebug) + { + return; + } } _showModal = true; - if (ImGui.BeginPopupModal("Texture Compression in Progress", ImGuiWindowFlags.AlwaysAutoResize)) + if (ImGui.BeginPopupModal("Texture Compression in Progress", UiSharedService.PopupWindowFlags)) { - DrawConversionModalContent(); + DrawConversionModalContent(showDebug); ImGui.EndPopup(); } else @@ -164,31 +186,190 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase } } - private void DrawConversionModalContent() + private void DrawConversionModalContent(bool isDebugPreview) { - var progress = _lastConversionProgress; + var scale = ImGuiHelpers.GlobalScale; + TextureConversionProgress? progress; +#if DEBUG + progress = isDebugPreview ? _debugConversionProgress : _lastConversionProgress; +#else + progress = _lastConversionProgress; +#endif var total = progress?.Total ?? Math.Max(_conversionTotalJobs, 1); var completed = progress != null - ? Math.Min(progress.Completed + 1, total) - : _conversionCurrentFileProgress; - var currentLabel = !string.IsNullOrEmpty(_conversionCurrentFileName) - ? _conversionCurrentFileName - : "Preparing..."; + ? Math.Clamp(progress.Completed + 1, 0, total) + : Math.Clamp(_conversionCurrentFileProgress, 0, total); + var percent = total > 0 ? Math.Clamp(completed / (float)total, 0f, 1f) : 0f; - ImGui.TextUnformatted($"Compressing textures ({completed}/{total})"); - UiSharedService.TextWrapped("Current file: " + currentLabel); + var job = progress?.CurrentJob; + var inputPath = job?.InputFile ?? string.Empty; + var targetLabel = job != null ? job.TargetType.ToString() : "Unknown"; + var currentLabel = !string.IsNullOrEmpty(inputPath) + ? Path.GetFileName(inputPath) + : !string.IsNullOrEmpty(_conversionCurrentFileName) ? _conversionCurrentFileName : "Preparing..."; + var mapKind = !string.IsNullOrEmpty(inputPath) + ? _textureMetadataHelper.DetermineMapKind(inputPath) + : TextureMapKind.Unknown; - if (_conversionFailed) + var accent = UIColors.Get("LightlessPurple"); + var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.18f); + var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.4f); + var headerHeight = MathF.Max(ImGui.GetTextLineHeightWithSpacing() * 2.6f, 46f * scale); + + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize))) + using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(12f * scale, 6f * scale))) + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(12f * scale, 2f * scale))) + using (ImRaii.PushColor(ImGuiCol.ChildBg, UiSharedService.Color(accentBg))) + using (ImRaii.PushColor(ImGuiCol.Border, UiSharedService.Color(accentBorder))) + using (var header = ImRaii.Child("compressionHeader", new Vector2(-1f, headerHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) { - UiSharedService.ColorText("Conversion encountered errors. Please review the log for details.", ImGuiColors.DalamudRed); + if (header) + { + if (ImGui.BeginTable("compressionHeaderTable", 2, + ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.NoHostExtendX)) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + DrawCompressionTitle(accent, scale); + + var statusText = isDebugPreview ? "Preview mode" : "Working..."; + var statusColor = isDebugPreview ? UIColors.Get("LightlessYellow") : ImGuiColors.DalamudGrey; + UiSharedService.ColorText(statusText, statusColor); + + ImGui.TableNextColumn(); + var progressText = $"{completed}/{total}"; + var percentText = $"{percent * 100f:0}%"; + var summaryText = $"{progressText} ({percentText})"; + var summaryWidth = ImGui.CalcTextSize(summaryText).X; + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + MathF.Max(0f, ImGui.GetColumnWidth() - summaryWidth)); + UiSharedService.ColorText(summaryText, ImGuiColors.DalamudGrey); + + ImGui.EndTable(); + } + } } - if (_uiSharedService.IconTextButton(FontAwesomeIcon.StopCircle, "Cancel conversion")) + ImGuiHelpers.ScaledDummy(6); + + using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 4f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.FramePadding, new Vector2(0f, 4f * scale))) + using (ImRaii.PushColor(ImGuiCol.FrameBg, UiSharedService.Color(new Vector4(0.15f, 0.15f, 0.18f, 1f)))) + using (ImRaii.PushColor(ImGuiCol.PlotHistogram, UiSharedService.Color(accent))) { - _conversionCancellationTokenSource.Cancel(); + ImGui.ProgressBar(percent, new Vector2(-1f, 0f), $"{percent * 100f:0}%"); } - UiSharedService.SetScaledWindowSize(520); + ImGuiHelpers.ScaledDummy(6); + + var infoAccent = UIColors.Get("LightlessBlue"); + var infoBg = new Vector4(infoAccent.X, infoAccent.Y, infoAccent.Z, 0.12f); + var infoBorder = new Vector4(infoAccent.X, infoAccent.Y, infoAccent.Z, 0.32f); + const int detailRows = 3; + var detailHeight = MathF.Max(ImGui.GetTextLineHeightWithSpacing() * (detailRows + 1.2f), 72f * scale); + + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 5f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize))) + using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(10f * scale, 6f * scale))) + using (ImRaii.PushColor(ImGuiCol.ChildBg, UiSharedService.Color(infoBg))) + using (ImRaii.PushColor(ImGuiCol.Border, UiSharedService.Color(infoBorder))) + using (var details = ImRaii.Child("compressionDetail", new Vector2(-1f, detailHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) + { + if (details) + { + if (ImGui.BeginTable("compressionDetailTable", 2, + ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.PadOuterX)) + { + DrawDetailRow("Current file", currentLabel, inputPath); + DrawDetailRow("Target format", targetLabel, null); + DrawDetailRow("Map type", mapKind.ToString(), null); + ImGui.EndTable(); + } + } + } + + if (_conversionFailed && !isDebugPreview) + { + ImGuiHelpers.ScaledDummy(4); + _uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, ImGuiColors.DalamudRed); + ImGui.SameLine(0f, 6f * scale); + UiSharedService.TextWrapped("Conversion encountered errors. Please review the log for details.", color: ImGuiColors.DalamudRed); + } + + ImGuiHelpers.ScaledDummy(6); + if (!isDebugPreview) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.StopCircle, "Cancel conversion")) + { + _conversionCancellationTokenSource.Cancel(); + } + } + else + { +#if DEBUG + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Times, "Close preview")) + { + CloseDebugCompressionModal(); + } +#endif + } + + UiSharedService.SetScaledWindowSize(600); + + void DrawDetailRow(string label, string value, string? tooltip) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudGrey)) + { + ImGui.TextUnformatted(label); + } + ImGui.TableNextColumn(); + ImGui.TextUnformatted(value); + if (!string.IsNullOrEmpty(tooltip)) + { + UiSharedService.AttachToolTip(tooltip); + } + } + + void DrawCompressionTitle(Vector4 iconColor, float localScale) + { + const string title = "Texture Compression"; + var spacing = 6f * localScale; + + var iconText = FontAwesomeIcon.CompressArrowsAlt.ToIconString(); + Vector2 iconSize; + using (_uiSharedService.IconFont.Push()) + { + iconSize = ImGui.CalcTextSize(iconText); + } + + Vector2 titleSize; + using (_uiSharedService.MediumFont.Push()) + { + titleSize = ImGui.CalcTextSize(title); + } + + var lineHeight = MathF.Max(iconSize.Y, titleSize.Y); + var iconOffsetY = (lineHeight - iconSize.Y) / 2f; + var textOffsetY = (lineHeight - titleSize.Y) / 2f; + + var start = ImGui.GetCursorScreenPos(); + var drawList = ImGui.GetWindowDrawList(); + + using (_uiSharedService.IconFont.Push()) + { + drawList.AddText(new Vector2(start.X, start.Y + iconOffsetY), UiSharedService.Color(iconColor), iconText); + } + + using (_uiSharedService.MediumFont.Push()) + { + var textPos = new Vector2(start.X + iconSize.X + spacing, start.Y + textOffsetY); + drawList.AddText(textPos, ImGui.GetColorU32(ImGuiCol.Text), title); + } + + ImGui.Dummy(new Vector2(iconSize.X + spacing + titleSize.X, lineHeight)); + } } private void ResetConversionModalState() @@ -202,6 +383,41 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase _conversionTotalJobs = 0; } +#if DEBUG + private void OpenCompressionDebugModal() + { + if (_conversionTask != null && !_conversionTask.IsCompleted) + { + return; + } + + _debugCompressionModalOpen = true; + _debugConversionProgress = new TextureConversionProgress( + Completed: 3, + Total: 10, + CurrentJob: new TextureConversionJob( + @"C:\Lightless\Mods\Textures\example_diffuse.tex", + @"C:\Lightless\Mods\Textures\example_diffuse_bc7.tex", + Penumbra.Api.Enums.TextureType.Bc7Tex)); + _showModal = true; + _modalOpen = false; + } + + private void ResetDebugCompressionModalState() + { + _debugCompressionModalOpen = false; + _debugConversionProgress = null; + } + + private void CloseDebugCompressionModal() + { + ResetDebugCompressionModalState(); + _showModal = false; + _modalOpen = false; + ImGui.CloseCurrentPopup(); + } +#endif + private void RefreshAnalysisCache() { if (!_hasUpdate) @@ -757,6 +973,16 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase ResetTextureFilters(); InvalidateTextureRows(); _conversionFailed = false; +#if DEBUG + ResetDebugCompressionModalState(); +#endif + var savedFormatSort = _configService.Current.TextureFormatSortMode; + if (!Enum.IsDefined(typeof(TextureFormatSortMode), savedFormatSort)) + { + savedFormatSort = TextureFormatSortMode.None; + } + + SetTextureFormatSortMode(savedFormatSort, persist: false); } protected override void Dispose(bool disposing) @@ -1955,6 +2181,17 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase { InvalidateTextureRows(); } +#if DEBUG + ImGui.SameLine(); + using (ImRaii.Disabled(conversionRunning || !UiSharedService.CtrlPressed())) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Cog, "Preview popup (debug)", 200f * scale)) + { + OpenCompressionDebugModal(); + } + } + UiSharedService.AttachToolTip("Hold CTRL to open the compression popup preview."); +#endif TextureRow? lastSelected = null; using (var table = ImRaii.Table("textureDataTable", 9, @@ -1973,26 +2210,56 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase ImGui.TableSetupColumn("Original", ImGuiTableColumnFlags.PreferSortDescending); ImGui.TableSetupColumn("Compressed", ImGuiTableColumnFlags.PreferSortDescending); ImGui.TableSetupScrollFreeze(0, 1); - ImGui.TableHeadersRow(); + DrawTextureTableHeaderRow(); var targets = _textureCompressionService.SelectableTargets; IEnumerable orderedRows = rows; var sortSpecs = ImGui.TableGetSortSpecs(); + var sizeSortColumn = -1; + var sizeSortDirection = ImGuiSortDirection.Ascending; if (sortSpecs.SpecsCount > 0) { var spec = sortSpecs.Specs[0]; - orderedRows = spec.ColumnIndex switch + if (spec.ColumnIndex is 7 or 8) { - 7 => spec.SortDirection == ImGuiSortDirection.Ascending - ? rows.OrderBy(r => r.OriginalSize) - : rows.OrderByDescending(r => r.OriginalSize), - 8 => spec.SortDirection == ImGuiSortDirection.Ascending - ? rows.OrderBy(r => r.CompressedSize) - : rows.OrderByDescending(r => r.CompressedSize), - _ => rows - }; + sizeSortColumn = spec.ColumnIndex; + sizeSortDirection = spec.SortDirection; + } + } + var hasSizeSort = sizeSortColumn != -1; + var indexedRows = rows.Select((row, idx) => (row, idx)); + + if (_textureFormatSortMode != TextureFormatSortMode.None) + { + bool compressedFirst = _textureFormatSortMode == TextureFormatSortMode.CompressedFirst; + int GroupKey(TextureRow row) => row.IsAlreadyCompressed == compressedFirst ? 0 : 1; + long SizeKey(TextureRow row) => sizeSortColumn == 7 ? row.OriginalSize : row.CompressedSize; + + var ordered = indexedRows.OrderBy(pair => GroupKey(pair.row)); + if (hasSizeSort) + { + ordered = sizeSortDirection == ImGuiSortDirection.Ascending + ? ordered.ThenBy(pair => SizeKey(pair.row)) + : ordered.ThenByDescending(pair => SizeKey(pair.row)); + } + + orderedRows = ordered + .ThenBy(pair => pair.idx) + .Select(pair => pair.row); + } + else if (hasSizeSort) + { + long SizeKey(TextureRow row) => sizeSortColumn == 7 ? row.OriginalSize : row.CompressedSize; + + orderedRows = sizeSortDirection == ImGuiSortDirection.Ascending + ? indexedRows.OrderBy(pair => SizeKey(pair.row)).ThenBy(pair => pair.idx).Select(pair => pair.row) + : indexedRows.OrderByDescending(pair => SizeKey(pair.row)).ThenBy(pair => pair.idx).Select(pair => pair.row); + } + + if (sortSpecs.SpecsCount > 0) + { sortSpecs.SpecsDirty = false; } @@ -2034,6 +2301,79 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase } } } + + private void DrawTextureTableHeaderRow() + { + ImGui.TableNextRow(ImGuiTableRowFlags.Headers); + + DrawHeaderCell(0, "##select"); + DrawHeaderCell(1, "Texture"); + DrawHeaderCell(2, "Slot"); + DrawHeaderCell(3, "Map"); + DrawFormatHeaderCell(); + DrawHeaderCell(5, "Recommended"); + DrawHeaderCell(6, "Target"); + DrawHeaderCell(7, "Original"); + DrawHeaderCell(8, "Compressed"); + } + + private static void DrawHeaderCell(int columnIndex, string label) + { + ImGui.TableSetColumnIndex(columnIndex); + ImGui.TableHeader(label); + } + + private void DrawFormatHeaderCell() + { + ImGui.TableSetColumnIndex(4); + ImGui.TableHeader(GetFormatHeaderLabel()); + + if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) + { + CycleTextureFormatSortMode(); + } + + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Click to cycle sort: normal, compressed first, uncompressed first."); + } + } + + private string GetFormatHeaderLabel() + => _textureFormatSortMode switch + { + TextureFormatSortMode.CompressedFirst => "Format (C)##formatHeader", + TextureFormatSortMode.UncompressedFirst => "Format (U)##formatHeader", + _ => "Format##formatHeader" + }; + + private void SetTextureFormatSortMode(TextureFormatSortMode mode, bool persist = true) + { + if (_textureFormatSortMode == mode) + { + return; + } + + _textureFormatSortMode = mode; + if (persist) + { + _configService.Current.TextureFormatSortMode = mode; + _configService.Save(); + } + } + + private void CycleTextureFormatSortMode() + { + var nextMode = _textureFormatSortMode switch + { + TextureFormatSortMode.None => TextureFormatSortMode.CompressedFirst, + TextureFormatSortMode.CompressedFirst => TextureFormatSortMode.UncompressedFirst, + _ => TextureFormatSortMode.None + }; + + SetTextureFormatSortMode(nextMode); + } + private void StartTextureConversion() { if (_conversionTask != null && !_conversionTask.IsCompleted) @@ -2335,11 +2675,30 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase { if (_texturePreviews.TryGetValue(key, out var state)) { + var loadTask = state.LoadTask; + if (loadTask is { IsCompleted: false }) + { + _ = loadTask.ContinueWith(_ => + { + state.Texture?.Dispose(); + }, TaskScheduler.Default); + } + state.Texture?.Dispose(); _texturePreviews.Remove(key); } } + private void ClearHoverPreview(TextureRow row) + { + if (string.Equals(_selectedTextureKey, row.Key, StringComparison.Ordinal)) + { + return; + } + + ResetPreview(row.Key); + } + private TextureResolutionInfo? GetTextureResolution(TextureRow row) { if (_textureResolutionCache.TryGetValue(row.Key, out var cached)) @@ -2440,7 +2799,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase UiSharedService.AttachToolTip("Already stored in a compressed format; additional compression is disabled."); } - DrawSelectableColumn(isSelected, () => + var nameHovered = DrawSelectableColumn(isSelected, () => { var selectableLabel = $"{row.DisplayName}##texName{index}"; if (ImGui.Selectable(selectableLabel, isSelected)) @@ -2448,20 +2807,20 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase _selectedTextureKey = isSelected ? string.Empty : key; } - return () => UiSharedService.AttachToolTip($"{row.PrimaryFilePath}{UiSharedService.TooltipSeparator}{string.Join(Environment.NewLine, row.GamePaths)}"); + return null; }); - DrawSelectableColumn(isSelected, () => + _ = DrawSelectableColumn(isSelected, () => { ImGui.TextUnformatted(row.Slot); return null; }); - DrawSelectableColumn(isSelected, () => + _ = DrawSelectableColumn(isSelected, () => { ImGui.TextUnformatted(row.MapKind.ToString()); return null; }); - DrawSelectableColumn(isSelected, () => + _ = DrawSelectableColumn(isSelected, () => { Action? tooltipAction = null; ImGui.TextUnformatted(row.Format); @@ -2475,7 +2834,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase return tooltipAction; }); - DrawSelectableColumn(isSelected, () => + _ = DrawSelectableColumn(isSelected, () => { if (row.SuggestedTarget.HasValue) { @@ -2537,19 +2896,21 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase UiSharedService.AttachToolTip("This texture is already compressed and cannot be processed again."); } - DrawSelectableColumn(isSelected, () => + _ = DrawSelectableColumn(isSelected, () => { ImGui.TextUnformatted(UiSharedService.ByteToString(row.OriginalSize)); return null; }); - DrawSelectableColumn(isSelected, () => + _ = DrawSelectableColumn(isSelected, () => { ImGui.TextUnformatted(UiSharedService.ByteToString(row.CompressedSize)); return null; }); + + DrawTextureRowHoverTooltip(row, nameHovered); } - private static void DrawSelectableColumn(bool isSelected, Func draw) + private static bool DrawSelectableColumn(bool isSelected, Func draw) { ImGui.TableNextColumn(); if (isSelected) @@ -2558,6 +2919,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase } var after = draw(); + var hovered = ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled | ImGuiHoveredFlags.AllowWhenBlockedByActiveItem); if (isSelected) { @@ -2565,6 +2927,127 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase } after?.Invoke(); + return hovered; + } + + private void DrawTextureRowHoverTooltip(TextureRow row, bool isHovered) + { + if (!isHovered) + { + if (string.Equals(_textureHoverKey, row.Key, StringComparison.Ordinal)) + { + _textureHoverKey = string.Empty; + _textureHoverStartTime = 0; + ClearHoverPreview(row); + } + return; + } + + var now = ImGui.GetTime(); + if (!string.Equals(_textureHoverKey, row.Key, StringComparison.Ordinal)) + { + _textureHoverKey = row.Key; + _textureHoverStartTime = now; + } + + var elapsed = now - _textureHoverStartTime; + if (elapsed < TextureHoverPreviewDelaySeconds) + { + var progress = (float)Math.Clamp(elapsed / TextureHoverPreviewDelaySeconds, 0f, 1f); + DrawTextureRowTextTooltip(row, progress); + return; + } + + DrawTextureRowPreviewTooltip(row); + } + + private void DrawTextureRowTextTooltip(TextureRow row, float progress) + { + ImGui.BeginTooltip(); + ImGui.SetWindowFontScale(1f); + DrawTextureRowTooltipBody(row); + ImGuiHelpers.ScaledDummy(4); + DrawTextureHoverProgressBar(progress, GetTooltipContentWidth()); + ImGui.EndTooltip(); + } + + private void DrawTextureRowPreviewTooltip(TextureRow row) + { + ImGui.BeginTooltip(); + ImGui.SetWindowFontScale(1f); + + DrawTextureRowTooltipBody(row); + ImGuiHelpers.ScaledDummy(4); + + var previewSize = new Vector2(TextureHoverPreviewSize * ImGuiHelpers.GlobalScale); + var (previewTexture, previewLoading, previewError) = GetTexturePreview(row); + if (previewTexture != null) + { + ImGui.Image(previewTexture.Handle, previewSize); + } + else + { + using (ImRaii.Child("textureHoverPreview", previewSize, true)) + { + UiSharedService.TextWrapped(previewLoading ? "Loading preview..." : previewError ?? "Preview unavailable."); + } + } + ImGui.EndTooltip(); + } + + private static void DrawTextureRowTooltipBody(TextureRow row) + { + var text = row.GamePaths.Count > 0 + ? $"{row.PrimaryFilePath}{UiSharedService.TooltipSeparator}{string.Join(Environment.NewLine, row.GamePaths)}" + : row.PrimaryFilePath; + + var wrapWidth = GetTextureHoverTooltipWidth(); + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + wrapWidth); + if (text.Contains(UiSharedService.TooltipSeparator, StringComparison.Ordinal)) + { + var splitText = text.Split(UiSharedService.TooltipSeparator, StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < splitText.Length; i++) + { + ImGui.TextUnformatted(splitText[i]); + if (i != splitText.Length - 1) + { + ImGui.Separator(); + } + } + } + else + { + ImGui.TextUnformatted(text); + } + ImGui.PopTextWrapPos(); + } + + private static void DrawTextureHoverProgressBar(float progress, float width) + { + var scale = ImGuiHelpers.GlobalScale; + var barHeight = 4f * scale; + var barWidth = width > 0f ? width : -1f; + using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 3f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.FramePadding, Vector2.Zero)) + using (ImRaii.PushColor(ImGuiCol.PlotHistogram, UiSharedService.Color(UIColors.Get("LightlessPurple")))) + { + ImGui.ProgressBar(progress, new Vector2(barWidth, barHeight), string.Empty); + } + } + + private static float GetTextureHoverTooltipWidth() + => ImGui.GetFontSize() * 35f; + + private static float GetTooltipContentWidth() + { + var min = ImGui.GetWindowContentRegionMin(); + var max = ImGui.GetWindowContentRegionMax(); + var width = max.X - min.X; + if (width <= 0f) + { + width = ImGui.GetContentRegionAvail().X; + } + return width; } private static void ApplyTextureRowBackground(TextureRow row, bool isSelected) diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index 2d9cdc1..7ed5629 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -17,7 +17,7 @@ namespace LightlessSync.UI; public class DownloadUi : WindowMediatorSubscriberBase { private readonly LightlessConfigService _configService; - private readonly ConcurrentDictionary> _currentDownloads = new(); + private readonly ConcurrentDictionary> _currentDownloads = new(); private readonly DalamudUtilService _dalamudUtilService; private readonly FileUploadManager _fileTransferManager; private readonly UiSharedService _uiShared; @@ -25,6 +25,8 @@ public class DownloadUi : WindowMediatorSubscriberBase private readonly ConcurrentDictionary _uploadingPlayers = new(); private readonly Dictionary _smoothed = []; private readonly Dictionary _downloadSpeeds = []; + private readonly Dictionary _downloadInitialTotals = []; + private byte _transferBoxTransparency = 100; private bool _notificationDismissed = true; @@ -66,6 +68,10 @@ public class DownloadUi : WindowMediatorSubscriberBase Mediator.Subscribe(this, (msg) => { _currentDownloads[msg.DownloadId] = msg.DownloadStatus; + // Capture initial totals when download starts + var totalFiles = msg.DownloadStatus.Values.Sum(s => s.TotalFiles); + var totalBytes = msg.DownloadStatus.Values.Sum(s => s.TotalBytes); + _downloadInitialTotals[msg.DownloadId] = (totalFiles, totalBytes); _notificationDismissed = false; }); Mediator.Subscribe(this, (msg) => @@ -164,10 +170,10 @@ public class DownloadUi : WindowMediatorSubscriberBase const float rounding = 6f; var shadowOffset = new Vector2(2, 2); - List>> transfers; + List>> transfers; try { - transfers = _currentDownloads.ToList(); + transfers = [.. _currentDownloads]; } catch (ArgumentException) { @@ -206,12 +212,16 @@ public class DownloadUi : WindowMediatorSubscriberBase var dlQueue = 0; var dlProg = 0; var dlDecomp = 0; + var dlComplete = 0; foreach (var entry in transfer.Value) { var fileStatus = entry.Value; switch (fileStatus.DownloadStatus) { + case DownloadStatus.Initializing: + dlQueue++; + break; case DownloadStatus.WaitingForSlot: dlSlot++; break; @@ -224,15 +234,20 @@ public class DownloadUi : WindowMediatorSubscriberBase case DownloadStatus.Decompressing: dlDecomp++; break; + case DownloadStatus.Completed: + dlComplete++; + break; } } + var isAllComplete = dlComplete > 0 && dlProg == 0 && dlDecomp == 0 && dlQueue == 0 && dlSlot == 0; + string statusText; if (dlProg > 0) { statusText = "Downloading"; } - else if (dlDecomp > 0 || (totalBytes > 0 && transferredBytes >= totalBytes)) + else if (dlDecomp > 0) { statusText = "Decompressing"; } @@ -244,6 +259,10 @@ public class DownloadUi : WindowMediatorSubscriberBase { statusText = "Waiting for slot"; } + else if (isAllComplete) + { + statusText = "Completed"; + } else { statusText = "Waiting"; @@ -309,7 +328,7 @@ public class DownloadUi : WindowMediatorSubscriberBase fillPercent = transferredBytes / (double)totalBytes; showFill = true; } - else if (dlDecomp > 0 || transferredBytes >= totalBytes) + else if (dlDecomp > 0 || dlComplete > 0 || transferredBytes >= totalBytes) { fillPercent = 1.0; showFill = true; @@ -341,10 +360,14 @@ public class DownloadUi : WindowMediatorSubscriberBase downloadText = $"{statusText} {UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}"; } - else if ((dlDecomp > 0 || transferredBytes >= totalBytes) && hasValidSize) + else if (dlDecomp > 0) { downloadText = "Decompressing"; } + else if (isAllComplete) + { + downloadText = "Completed"; + } else { // Waiting states @@ -417,6 +440,7 @@ public class DownloadUi : WindowMediatorSubscriberBase var totalDlQueue = 0; var totalDlProg = 0; var totalDlDecomp = 0; + var totalDlComplete = 0; var perPlayer = new List<( string Name, @@ -428,16 +452,21 @@ public class DownloadUi : WindowMediatorSubscriberBase int DlSlot, int DlQueue, int DlProg, - int DlDecomp)>(); + int DlDecomp, + int DlComplete)>(); foreach (var transfer in _currentDownloads) { var handler = transfer.Key; var statuses = transfer.Value.Values; - var playerTotalFiles = statuses.Sum(s => s.TotalFiles); - var playerTransferredFiles = statuses.Sum(s => s.TransferredFiles); - var playerTotalBytes = statuses.Sum(s => s.TotalBytes); + var (playerTotalFiles, playerTotalBytes) = _downloadInitialTotals.TryGetValue(handler, out var totals) + ? totals + : (statuses.Sum(s => s.TotalFiles), statuses.Sum(s => s.TotalBytes)); + + var playerTransferredFiles = statuses.Count(s => + s.DownloadStatus == DownloadStatus.Decompressing || + s.TransferredBytes >= s.TotalBytes); var playerTransferredBytes = statuses.Sum(s => s.TransferredBytes); totalFiles += playerTotalFiles; @@ -450,12 +479,17 @@ public class DownloadUi : WindowMediatorSubscriberBase var playerDlQueue = 0; var playerDlProg = 0; var playerDlDecomp = 0; + var playerDlComplete = 0; foreach (var entry in transfer.Value) { var fileStatus = entry.Value; switch (fileStatus.DownloadStatus) { + case DownloadStatus.Initializing: + playerDlQueue++; + totalDlQueue++; + break; case DownloadStatus.WaitingForSlot: playerDlSlot++; totalDlSlot++; @@ -472,6 +506,10 @@ public class DownloadUi : WindowMediatorSubscriberBase playerDlDecomp++; totalDlDecomp++; break; + case DownloadStatus.Completed: + playerDlComplete++; + totalDlComplete++; + break; } } @@ -497,7 +535,8 @@ public class DownloadUi : WindowMediatorSubscriberBase playerDlSlot, playerDlQueue, playerDlProg, - playerDlDecomp + playerDlDecomp, + playerDlComplete )); } @@ -521,7 +560,7 @@ public class DownloadUi : WindowMediatorSubscriberBase // Overall texts var headerText = - $"Downloading {transferredFiles}/{totalFiles} files [W:{totalDlSlot}/Q:{totalDlQueue}/P:{totalDlProg}/D:{totalDlDecomp}]"; + $"Downloading {transferredFiles}/{totalFiles} files [W:{totalDlSlot}/Q:{totalDlQueue}/P:{totalDlProg}/D:{totalDlDecomp}/C:{totalDlComplete}]"; var bytesText = $"{UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}"; @@ -544,7 +583,7 @@ public class DownloadUi : WindowMediatorSubscriberBase foreach (var p in perPlayer) { var line = - $"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}] {p.TransferredFiles}/{p.TotalFiles}"; + $"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}/C:{p.DlComplete}] {p.TransferredFiles}/{p.TotalFiles}"; var lineSize = ImGui.CalcTextSize(line); if (lineSize.X > contentWidth) @@ -662,7 +701,7 @@ public class DownloadUi : WindowMediatorSubscriberBase && p.TransferredBytes > 0; var labelLine = - $"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}] {p.TransferredFiles}/{p.TotalFiles}"; + $"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}/C:{p.DlComplete}] {p.TransferredFiles}/{p.TotalFiles}"; if (!showBar) { @@ -721,13 +760,18 @@ public class DownloadUi : WindowMediatorSubscriberBase // Text inside bar: downloading vs decompressing string barText; - var isDecompressing = p.DlDecomp > 0 && p.TransferredBytes >= p.TotalBytes && p.TotalBytes > 0; + var isDecompressing = p.DlDecomp > 0; + var isAllComplete = p.DlComplete > 0 && p.DlProg == 0 && p.DlDecomp == 0 && p.DlQueue == 0 && p.DlSlot == 0; if (isDecompressing) { // Keep bar full, static text showing decompressing barText = "Decompressing..."; } + else if (isAllComplete) + { + barText = "Completed"; + } else { var bytesInside = @@ -808,6 +852,7 @@ public class DownloadUi : WindowMediatorSubscriberBase var dlQueue = 0; var dlProg = 0; var dlDecomp = 0; + var dlComplete = 0; long totalBytes = 0; long transferredBytes = 0; @@ -817,22 +862,29 @@ public class DownloadUi : WindowMediatorSubscriberBase var fileStatus = entry.Value; switch (fileStatus.DownloadStatus) { + case DownloadStatus.Initializing: dlQueue++; break; case DownloadStatus.WaitingForSlot: dlSlot++; break; case DownloadStatus.WaitingForQueue: dlQueue++; break; case DownloadStatus.Downloading: dlProg++; break; case DownloadStatus.Decompressing: dlDecomp++; break; + case DownloadStatus.Completed: dlComplete++; break; } totalBytes += fileStatus.TotalBytes; transferredBytes += fileStatus.TransferredBytes; } var progress = totalBytes > 0 ? (float)transferredBytes / totalBytes : 0f; + if (dlComplete > 0 && dlProg == 0 && dlDecomp == 0 && dlQueue == 0 && dlSlot == 0) + { + progress = 1f; + } string status; if (dlDecomp > 0) status = "decompressing"; else if (dlProg > 0) status = "downloading"; else if (dlQueue > 0) status = "queued"; else if (dlSlot > 0) status = "waiting"; + else if (dlComplete > 0) status = "completed"; else status = "completed"; downloadStatus.Add((item.Key.Name, progress, status)); diff --git a/LightlessSync/UI/DrawEntityFactory.cs b/LightlessSync/UI/DrawEntityFactory.cs index e7bcc87..08f81b6 100644 --- a/LightlessSync/UI/DrawEntityFactory.cs +++ b/LightlessSync/UI/DrawEntityFactory.cs @@ -217,6 +217,7 @@ public class DrawEntityFactory entry.PairStatus, handler?.LastAppliedDataBytes ?? -1, handler?.LastAppliedDataTris ?? -1, + handler?.LastAppliedApproximateEffectiveTris ?? -1, handler?.LastAppliedApproximateVRAMBytes ?? -1, handler?.LastAppliedApproximateEffectiveVRAMBytes ?? -1, handler); diff --git a/LightlessSync/UI/DtrEntry.cs b/LightlessSync/UI/DtrEntry.cs index ae94d5e..5aa69eb 100644 --- a/LightlessSync/UI/DtrEntry.cs +++ b/LightlessSync/UI/DtrEntry.cs @@ -103,10 +103,19 @@ public sealed class DtrEntry : IDisposable, IHostedService public async Task StopAsync(CancellationToken cancellationToken) { - await _cancellationTokenSource.CancelAsync().ConfigureAwait(false); + _cancellationTokenSource.Cancel(); + + if (_dalamudUtilService.IsOnFrameworkThread) + { + _logger.LogDebug("Skipping Lightfinder DTR wait on framework thread during shutdown."); + _cancellationTokenSource.Dispose(); + return; + } + try { - await _runTask!.ConfigureAwait(false); + if (_runTask != null) + await _runTask.ConfigureAwait(false); } catch (OperationCanceledException) { diff --git a/LightlessSync/UI/Handlers/IdDisplayHandler.cs b/LightlessSync/UI/Handlers/IdDisplayHandler.cs index 74a6571..46ebe7d 100644 --- a/LightlessSync/UI/Handlers/IdDisplayHandler.cs +++ b/LightlessSync/UI/Handlers/IdDisplayHandler.cs @@ -415,7 +415,9 @@ public class IdDisplayHandler var vramBytes = pair.LastAppliedApproximateEffectiveVRAMBytes >= 0 ? pair.LastAppliedApproximateEffectiveVRAMBytes : pair.LastAppliedApproximateVRAMBytes; - var triangleCount = pair.LastAppliedDataTris; + var triangleCount = pair.LastAppliedApproximateEffectiveTris >= 0 + ? pair.LastAppliedApproximateEffectiveTris + : pair.LastAppliedDataTris; if (vramBytes < 0 && triangleCount < 0) { return null; diff --git a/LightlessSync/UI/Models/PairUiEntry.cs b/LightlessSync/UI/Models/PairUiEntry.cs index c25b6fd..fcda8ec 100644 --- a/LightlessSync/UI/Models/PairUiEntry.cs +++ b/LightlessSync/UI/Models/PairUiEntry.cs @@ -21,6 +21,7 @@ public sealed record PairUiEntry( IndividualPairStatus? PairStatus, long LastAppliedDataBytes, long LastAppliedDataTris, + long LastAppliedApproximateEffectiveTris, long LastAppliedApproximateVramBytes, long LastAppliedApproximateEffectiveVramBytes, IPairHandlerAdapter? Handler) diff --git a/LightlessSync/UI/Models/TextureFormatSortMode.cs b/LightlessSync/UI/Models/TextureFormatSortMode.cs new file mode 100644 index 0000000..165e10d --- /dev/null +++ b/LightlessSync/UI/Models/TextureFormatSortMode.cs @@ -0,0 +1,8 @@ +namespace LightlessSync.UI.Models; + +public enum TextureFormatSortMode +{ + None = 0, + CompressedFirst = 1, + UncompressedFirst = 2 +} diff --git a/LightlessSync/UI/Models/VisiblePairSortMode.cs b/LightlessSync/UI/Models/VisiblePairSortMode.cs index ec133b9..615ac9f 100644 --- a/LightlessSync/UI/Models/VisiblePairSortMode.cs +++ b/LightlessSync/UI/Models/VisiblePairSortMode.cs @@ -7,4 +7,5 @@ public enum VisiblePairSortMode EffectiveVramUsage = 2, TriangleCount = 3, PreferredDirectPairs = 4, + EffectiveTriangleCount = 5, } diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index d6b435f..9c2f1ef 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -15,6 +15,7 @@ using LightlessSync.Interop.Ipc; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Configurations; using LightlessSync.LightlessConfiguration.Models; +using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; @@ -41,6 +42,7 @@ using System.Globalization; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Numerics; +using System.Runtime.InteropServices; using System.Text; using System.Text.Json; @@ -52,7 +54,7 @@ public class SettingsUi : WindowMediatorSubscriberBase private readonly CacheMonitor _cacheMonitor; private readonly LightlessConfigService _configService; private readonly UiThemeConfigService _themeConfigService; - private readonly ConcurrentDictionary> _currentDownloads = new(); + private readonly ConcurrentDictionary> _currentDownloads = new(); private readonly DalamudUtilService _dalamudUtilService; private readonly HttpClient _httpClient; private readonly FileCacheManager _fileCacheManager; @@ -108,8 +110,8 @@ public class SettingsUi : WindowMediatorSubscriberBase }; private readonly UiSharedService.TabOption[] _transferTabOptions = new UiSharedService.TabOption[2]; private readonly List> _serverTabOptions = new(4); - private readonly string[] _generalTreeNavOrder = new[] - { + private readonly string[] _generalTreeNavOrder = + [ "Import & Export", "Popup & Auto Fill", "Behavior", @@ -119,7 +121,8 @@ public class SettingsUi : WindowMediatorSubscriberBase "Colors", "Server Info Bar", "Nameplate", - }; + "Animation & Bones" + ]; private static readonly HashSet _generalNavSeparatorAfter = new(StringComparer.Ordinal) { "Popup & Auto Fill", @@ -581,6 +584,94 @@ public class SettingsUi : WindowMediatorSubscriberBase } } + private void DrawTriangleDecimationCounters() + { + HashSet trackedPairs = new(); + + var snapshot = _pairUiService.GetSnapshot(); + + foreach (var pair in snapshot.DirectPairs) + { + trackedPairs.Add(pair); + } + + foreach (var group in snapshot.GroupPairs.Values) + { + foreach (var pair in group) + { + trackedPairs.Add(pair); + } + } + + long totalOriginalTris = 0; + long totalEffectiveTris = 0; + var hasData = false; + + foreach (var pair in trackedPairs) + { + if (!pair.IsVisible) + continue; + + var original = pair.LastAppliedDataTris; + var effective = pair.LastAppliedApproximateEffectiveTris; + + if (original >= 0) + { + hasData = true; + totalOriginalTris += original; + } + + if (effective >= 0) + { + hasData = true; + totalEffectiveTris += effective; + } + } + + if (!hasData) + { + ImGui.TextDisabled("Triangle usage has not been calculated yet."); + return; + } + + var savedTris = Math.Max(0L, totalOriginalTris - totalEffectiveTris); + var originalText = FormatTriangleCount(totalOriginalTris); + var effectiveText = FormatTriangleCount(totalEffectiveTris); + var savedText = FormatTriangleCount(savedTris); + + ImGui.TextUnformatted($"Total triangle usage (original): {originalText}"); + ImGui.TextUnformatted($"Total triangle usage (effective): {effectiveText}"); + + if (savedTris > 0) + { + UiSharedService.ColorText($"Triangles saved by decimation: {savedText}", UIColors.Get("LightlessGreen")); + } + else + { + ImGui.TextUnformatted($"Triangles saved by decimation: {savedText}"); + } + + static string FormatTriangleCount(long triangleCount) + { + if (triangleCount < 0) + { + return "n/a"; + } + + if (triangleCount >= 1_000_000) + { + return FormattableString.Invariant($"{triangleCount / 1_000_000d:0.#}m tris"); + } + + if (triangleCount >= 1_000) + { + return FormattableString.Invariant($"{triangleCount / 1_000d:0.#}k tris"); + } + + return $"{triangleCount} tris"; + } + } + private void DrawThemeVectorRow(MainStyle.StyleVector2Option option) { ImGui.TableNextRow(); @@ -870,10 +961,11 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.DrawHelpText( $"The download window will show the current progress of outstanding downloads.{Environment.NewLine}{Environment.NewLine}" + - $"What do W/Q/P/D stand for?{Environment.NewLine}W = Waiting for Slot (see Maximum Parallel Downloads){Environment.NewLine}" + + $"What do W/Q/P/D/C stand for?{Environment.NewLine}W = Waiting for Slot (see Maximum Parallel Downloads){Environment.NewLine}" + $"Q = Queued on Server, waiting for queue ready signal{Environment.NewLine}" + $"P = Processing download (aka downloading){Environment.NewLine}" + - $"D = Decompressing download"); + $"D = Decompressing download{Environment.NewLine}" + + $"C = Completed download"); if (!_configService.Current.ShowTransferWindow) ImGui.BeginDisabled(); ImGui.Indent(); @@ -1148,7 +1240,7 @@ public class SettingsUi : WindowMediatorSubscriberBase private async Task?> RunSpeedTest(List servers, CancellationToken token) { - List speedTestResults = new(); + List speedTestResults = []; foreach (var server in servers) { HttpResponseMessage? result = null; @@ -1533,6 +1625,7 @@ public class SettingsUi : WindowMediatorSubscriberBase DrawPairPropertyRow("Approx. VRAM", FormatBytes(pair.LastAppliedApproximateVRAMBytes)); DrawPairPropertyRow("Effective VRAM", FormatBytes(pair.LastAppliedApproximateEffectiveVRAMBytes)); DrawPairPropertyRow("Last Triangles", pair.LastAppliedDataTris < 0 ? "n/a" : pair.LastAppliedDataTris.ToString(CultureInfo.InvariantCulture)); + DrawPairPropertyRow("Effective Triangles", pair.LastAppliedApproximateEffectiveTris < 0 ? "n/a" : pair.LastAppliedApproximateEffectiveTris.ToString(CultureInfo.InvariantCulture)); ImGui.EndTable(); } @@ -1964,14 +2057,25 @@ public class SettingsUi : WindowMediatorSubscriberBase { using (ImRaii.PushIndent(20f)) { - if (_validationTask.IsCompleted) + if (_validationTask.IsCompletedSuccessfully) { UiSharedService.TextWrapped( $"The storage validation has completed and removed {_validationTask.Result.Count} invalid files from storage."); } + else if (_validationTask.IsCanceled) + { + UiSharedService.ColorTextWrapped( + "Storage validation was cancelled.", + UIColors.Get("LightlessYellow")); + } + else if (_validationTask.IsFaulted) + { + UiSharedService.ColorTextWrapped( + "Storage validation failed with an error.", + UIColors.Get("DimRed")); + } else { - UiSharedService.TextWrapped( $"Storage validation is running: {_currentProgress.Item1}/{_currentProgress.Item2}"); if (_currentProgress.Item3 != null) @@ -3127,10 +3231,102 @@ public class SettingsUi : WindowMediatorSubscriberBase } ImGui.Separator(); + ImGui.Dummy(new Vector2(10)); + _uiShared.BigText("Animation"); + + using (var animationTree = BeginGeneralTree("Animation & Bones", UIColors.Get("LightlessPurple"))) + { + if (animationTree.Visible) + { + ImGui.TextUnformatted("Animation Options"); + + var modes = new[] + { + AnimationValidationMode.Unsafe, + AnimationValidationMode.Safe, + AnimationValidationMode.Safest, + }; + + var labels = new[] + { + "Unsafe", + "Safe (Race)", + "Safest (Race + Bones)", + }; + + var tooltips = new[] + { + "No validation. Fastest, but may allow incompatible animations (riskier).", + "Validates skeleton race + modded skeleton check (recommended).", + "Requires matching skeleton race + bone compatibility (strictest).", + }; + + + var currentMode = _configService.Current.AnimationValidationMode; + int selectedIndex = Array.IndexOf(modes, currentMode); + if (selectedIndex < 0) selectedIndex = 1; + + ImGui.SetNextItemWidth(140 * ImGuiHelpers.GlobalScale); + + bool open = ImGui.BeginCombo("Animation validation", labels[selectedIndex]); + + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(tooltips[selectedIndex]); + + if (open) + { + for (int i = 0; i < modes.Length; i++) + { + bool isSelected = (i == selectedIndex); + + if (ImGui.Selectable(labels[i], isSelected)) + { + selectedIndex = i; + _configService.Current.AnimationValidationMode = modes[i]; + _configService.Save(); + } + + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(tooltips[i]); + + if (isSelected) + ImGui.SetItemDefaultFocus(); + } + + ImGui.EndCombo(); + } + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + + var cfg = _configService.Current; + + bool oneBased = cfg.AnimationAllowOneBasedShift; + if (ImGui.Checkbox("Treat 1-based PAP indices as compatible", ref oneBased)) + { + cfg.AnimationAllowOneBasedShift = oneBased; + _configService.Save(); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Fixes off-by-one PAPs (one bone differance in bones and PAP). Can also increase crashing, toggle off if alot of crashing is happening"); + + bool neighbor = cfg.AnimationAllowNeighborIndexTolerance; + if (ImGui.Checkbox("Allow 1+- bone index tolerance", ref neighbor)) + { + cfg.AnimationAllowNeighborIndexTolerance = neighbor; + _configService.Save(); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Looser matching on bone matching. Can reduce false blocks happening but also reduces safety and more prone to crashing."); + + ImGui.TreePop(); + animationTree.MarkContentEnd(); + } + } ImGui.EndChild(); ImGui.EndGroup(); + ImGui.Separator(); generalSelune.DrawHighlightOnly(ImGui.GetIO().DeltaTime); } } @@ -3220,6 +3416,7 @@ public class SettingsUi : WindowMediatorSubscriberBase return 1f - (elapsed / GeneralTreeHighlightDuration); } + [StructLayout(LayoutKind.Auto)] private struct GeneralTreeScope : IDisposable { private readonly bool _visible; @@ -3527,7 +3724,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.DrawHelpText("Controls whether Lightless reduces index textures that exceed the size limit."); var dimensionOptions = new[] { 512, 1024, 2048, 4096 }; - var optionLabels = dimensionOptions.Select(static value => value.ToString()).ToArray(); + var optionLabels = dimensionOptions.Select(selector: static value => value.ToString()).ToArray(); var currentDimension = textureConfig.TextureDownscaleMaxDimension; var selectedIndex = Array.IndexOf(dimensionOptions, currentDimension); if (selectedIndex < 0) @@ -3553,6 +3750,14 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.SameLine(); _uiShared.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), new SeStringUtils.RichTextEntry("If disabled, saved + effective VRAM usage information will not work.", UIColors.Get("LightlessYellow"))); + var skipPreferredDownscale = textureConfig.SkipTextureDownscaleForPreferredPairs; + if (ImGui.Checkbox("Skip downscale for preferred/direct pairs", ref skipPreferredDownscale)) + { + textureConfig.SkipTextureDownscaleForPreferredPairs = skipPreferredDownscale; + _playerPerformanceConfigService.Save(); + } + _uiShared.DrawHelpText("When enabled, textures for direct pairs with preferred permissions are left untouched."); + if (!textureConfig.EnableNonIndexTextureMipTrim && !textureConfig.EnableIndexTextureDownscale) { UiSharedService.ColorTextWrapped("Both trimming and downscale are disabled. Lightless will keep original textures regardless of size.", UIColors.Get("DimRed")); @@ -3580,6 +3785,160 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.TreePop(); } + ImGui.Separator(); + + if (_uiShared.MediumTreeNode("Model Optimization", UIColors.Get("DimRed"))) + { + _uiShared.MediumText("Warning", UIColors.Get("DimRed")); + _uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("Model decimation is a "), + new SeStringUtils.RichTextEntry("destructive", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry(" process and may cause broken or incorrect character appearances.")); + + + _uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("This feature is encouraged to help "), + new SeStringUtils.RichTextEntry("lower-end systems with limited VRAM", UIColors.Get("LightlessYellow"), true), + new SeStringUtils.RichTextEntry(" and for use in "), + new SeStringUtils.RichTextEntry("performance-critical scenarios", UIColors.Get("LightlessYellow"), true), + new SeStringUtils.RichTextEntry(".")); + + _uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("Runtime decimation "), + new SeStringUtils.RichTextEntry("MAY", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry(" cause higher load on the system when processing downloads.")); + + _uiShared.DrawNoteLine("!!! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("When enabled, we cannot provide support for appearance issues caused by this setting!", UIColors.Get("DimRed"), true)); + + ImGui.Dummy(new Vector2(15)); + + _uiShared.DrawNoteLine("! ", UIColors.Get("LightlessGreen"), + new SeStringUtils.RichTextEntry("If a mesh exceeds the "), + new SeStringUtils.RichTextEntry("triangle threshold", UIColors.Get("LightlessGreen"), true), + new SeStringUtils.RichTextEntry(", it will be decimated automatically to the set "), + new SeStringUtils.RichTextEntry("target triangle ratio", UIColors.Get("LightlessGreen"), true), + new SeStringUtils.RichTextEntry(". This will reduce quality of the mesh or may break it's intended structure.")); + + + var performanceConfig = _playerPerformanceConfigService.Current; + var enableDecimation = performanceConfig.EnableModelDecimation; + if (ImGui.Checkbox("Enable model decimation", ref enableDecimation)) + { + performanceConfig.EnableModelDecimation = enableDecimation; + _playerPerformanceConfigService.Save(); + } + _uiShared.DrawHelpText("When enabled, Lightless generates a decimated copy of given model after download."); + + var keepOriginalModels = performanceConfig.KeepOriginalModelFiles; + if (ImGui.Checkbox("Keep original model files", ref keepOriginalModels)) + { + performanceConfig.KeepOriginalModelFiles = keepOriginalModels; + _playerPerformanceConfigService.Save(); + } + _uiShared.DrawHelpText("When disabled, Lightless removes the original model after a decimated copy is created."); + ImGui.SameLine(); + _uiShared.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), new SeStringUtils.RichTextEntry("If disabled, saved + effective triangle usage information will not work.", UIColors.Get("LightlessYellow"))); + + var skipPreferredDecimation = performanceConfig.SkipModelDecimationForPreferredPairs; + if (ImGui.Checkbox("Skip decimation for preferred/direct pairs", ref skipPreferredDecimation)) + { + performanceConfig.SkipModelDecimationForPreferredPairs = skipPreferredDecimation; + _playerPerformanceConfigService.Save(); + } + _uiShared.DrawHelpText("When enabled, models for direct pairs with preferred permissions are left untouched."); + + var triangleThreshold = performanceConfig.ModelDecimationTriangleThreshold; + ImGui.SetNextItemWidth(300 * ImGuiHelpers.GlobalScale); + if (ImGui.SliderInt("Decimate models above", ref triangleThreshold, 8_000, 100_000)) + { + performanceConfig.ModelDecimationTriangleThreshold = Math.Clamp(triangleThreshold, 8_000, 100_000); + _playerPerformanceConfigService.Save(); + } + ImGui.SameLine(); + ImGui.Text("triangles"); + _uiShared.DrawHelpText($"Models below this triangle count are left untouched.{UiSharedService.TooltipSeparator}Default: 50,000"); + + var targetPercent = (float)(performanceConfig.ModelDecimationTargetRatio * 100.0); + var clampedPercent = Math.Clamp(targetPercent, 60f, 99f); + if (Math.Abs(clampedPercent - targetPercent) > float.Epsilon) + { + performanceConfig.ModelDecimationTargetRatio = clampedPercent / 100.0; + _playerPerformanceConfigService.Save(); + targetPercent = clampedPercent; + } + ImGui.SetNextItemWidth(300 * ImGuiHelpers.GlobalScale); + if (ImGui.SliderFloat("Target triangle ratio", ref targetPercent, 60f, 99f, "%.0f%%")) + { + performanceConfig.ModelDecimationTargetRatio = Math.Clamp(targetPercent / 100f, 0.6f, 0.99f); + _playerPerformanceConfigService.Save(); + } + _uiShared.DrawHelpText($"Target ratio relative to original triangle count (80% keeps 80% of triangles).{UiSharedService.TooltipSeparator}Default: 80%"); + + ImGui.Dummy(new Vector2(15)); + ImGui.TextUnformatted("Decimation targets"); + _uiShared.DrawHelpText("Hair mods are always excluded from decimation."); + + _uiShared.DrawNoteLine("! ", UIColors.Get("LightlessGreen"), + new SeStringUtils.RichTextEntry("Automatic decimation will only target the selected "), + new SeStringUtils.RichTextEntry("decimation targets", UIColors.Get("LightlessGreen"), true), + new SeStringUtils.RichTextEntry(".")); + + _uiShared.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), + new SeStringUtils.RichTextEntry("It is advised to not decimate any body related meshes which includes: "), + new SeStringUtils.RichTextEntry("facial mods + sculpts, chest, legs, hands and feet", UIColors.Get("LightlessYellow"), true), + new SeStringUtils.RichTextEntry(".")); + + _uiShared.DrawNoteLine("!!! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("Remember, automatic decimation is not perfect and can cause meshes to be ruined, especially hair mods.", UIColors.Get("DimRed"), true)); + + var allowBody = performanceConfig.ModelDecimationAllowBody; + if (ImGui.Checkbox("Body", ref allowBody)) + { + performanceConfig.ModelDecimationAllowBody = allowBody; + _playerPerformanceConfigService.Save(); + } + + var allowFaceHead = performanceConfig.ModelDecimationAllowFaceHead; + if (ImGui.Checkbox("Face/head", ref allowFaceHead)) + { + performanceConfig.ModelDecimationAllowFaceHead = allowFaceHead; + _playerPerformanceConfigService.Save(); + } + + var allowTail = performanceConfig.ModelDecimationAllowTail; + if (ImGui.Checkbox("Tails/Ears", ref allowTail)) + { + performanceConfig.ModelDecimationAllowTail = allowTail; + _playerPerformanceConfigService.Save(); + } + + var allowClothing = performanceConfig.ModelDecimationAllowClothing; + if (ImGui.Checkbox("Clothing (body/legs/shoes/gloves/hats)", ref allowClothing)) + { + performanceConfig.ModelDecimationAllowClothing = allowClothing; + _playerPerformanceConfigService.Save(); + } + + var allowAccessories = performanceConfig.ModelDecimationAllowAccessories; + if (ImGui.Checkbox("Accessories (earring/rings/bracelet/necklace)", ref allowAccessories)) + { + performanceConfig.ModelDecimationAllowAccessories = allowAccessories; + _playerPerformanceConfigService.Save(); + } + + ImGui.Dummy(new Vector2(5)); + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessGrey"), 3f); + + ImGui.Dummy(new Vector2(5)); + DrawTriangleDecimationCounters(); + ImGui.Dummy(new Vector2(5)); + + UiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 1.5f); + ImGui.TreePop(); + } + ImGui.Separator(); ImGui.Dummy(new Vector2(10)); diff --git a/LightlessSync/UI/ZoneChatUi.cs b/LightlessSync/UI/ZoneChatUi.cs index cb6dae8..a03ceab 100644 --- a/LightlessSync/UI/ZoneChatUi.cs +++ b/LightlessSync/UI/ZoneChatUi.cs @@ -205,10 +205,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private void ApplyUiVisibilitySettings() { - var config = _chatConfigService.Current; _uiBuilder.DisableUserUiHide = true; - _uiBuilder.DisableCutsceneUiHide = config.ShowInCutscenes; - _uiBuilder.DisableGposeUiHide = config.ShowInGpose; + _uiBuilder.DisableCutsceneUiHide = true; } private bool ShouldHide() @@ -220,6 +218,16 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase return true; } + if (!config.ShowInGpose && _dalamudUtilService.IsInGpose) + { + return true; + } + + if (!config.ShowInCutscenes && _dalamudUtilService.IsInCutscene) + { + return true; + } + if (config.HideInCombat && _dalamudUtilService.IsInCombat) { return true; diff --git a/LightlessSync/Utils/VariousExtensions.cs b/LightlessSync/Utils/VariousExtensions.cs index 0020bc9..d250279 100644 --- a/LightlessSync/Utils/VariousExtensions.cs +++ b/LightlessSync/Utils/VariousExtensions.cs @@ -57,7 +57,8 @@ public static class VariousExtensions } public static Dictionary> CheckUpdatedData(this CharacterData newData, Guid applicationBase, - CharacterData? oldData, ILogger logger, IPairPerformanceSubject cachedPlayer, bool forceApplyCustomization, bool forceApplyMods) + CharacterData? oldData, ILogger logger, IPairPerformanceSubject cachedPlayer, bool forceApplyCustomization, bool forceApplyMods, + bool suppressForcedRedrawOnForcedModApply = false) { oldData ??= new(); @@ -78,6 +79,7 @@ public static class VariousExtensions bool hasNewAndOldFileReplacements = newFileReplacements != null && existingFileReplacements != null; bool hasNewAndOldGlamourerData = newGlamourerData != null && existingGlamourerData != null; + var forceRedrawOnForcedApply = forceApplyMods && !suppressForcedRedrawOnForcedModApply; if (hasNewButNotOldFileReplacements || hasOldButNotNewFileReplacements || hasNewButNotOldGlamourerData || hasOldButNotNewGlamourerData) { @@ -100,7 +102,7 @@ public static class VariousExtensions { logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (FileReplacements not equal) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModFiles); charaDataToUpdate[objectKind].Add(PlayerChanges.ModFiles); - if (forceApplyMods || objectKind != ObjectKind.Player) + if (objectKind != ObjectKind.Player || forceRedrawOnForcedApply) { charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw); } @@ -167,7 +169,7 @@ public static class VariousExtensions if (objectKind != ObjectKind.Player) continue; bool manipDataDifferent = !string.Equals(oldData.ManipulationData, newData.ManipulationData, StringComparison.Ordinal); - if (manipDataDifferent || forceApplyMods) + if (manipDataDifferent || forceRedrawOnForcedApply) { logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff manip data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModManip); charaDataToUpdate[objectKind].Add(PlayerChanges.ModManip); diff --git a/LightlessSync/WebAPI/Files/FileDownloadManager.cs b/LightlessSync/WebAPI/Files/FileDownloadManager.cs index 8aa2b0b..2013b50 100644 --- a/LightlessSync/WebAPI/Files/FileDownloadManager.cs +++ b/LightlessSync/WebAPI/Files/FileDownloadManager.cs @@ -6,6 +6,7 @@ using LightlessSync.FileCache; using LightlessSync.LightlessConfiguration; using LightlessSync.PlayerData.Handlers; using LightlessSync.Services.Mediator; +using LightlessSync.Services.ModelDecimation; using LightlessSync.Services.TextureCompression; using LightlessSync.WebAPI.Files.Models; using Microsoft.Extensions.Logging; @@ -17,19 +18,21 @@ namespace LightlessSync.WebAPI.Files; public partial class FileDownloadManager : DisposableMediatorSubscriberBase { - private readonly Dictionary _downloadStatus; - private readonly object _downloadStatusLock = new(); + private readonly ConcurrentDictionary _downloadStatus; private readonly FileCompactor _fileCompactor; private readonly FileCacheManager _fileDbManager; private readonly FileTransferOrchestrator _orchestrator; private readonly LightlessConfigService _configService; private readonly TextureDownscaleService _textureDownscaleService; + private readonly ModelDecimationService _modelDecimationService; private readonly TextureMetadataHelper _textureMetadataHelper; private readonly ConcurrentDictionary _activeDownloadStreams; private readonly SemaphoreSlim _decompressGate = new(Math.Max(1, Environment.ProcessorCount / 2), Math.Max(1, Environment.ProcessorCount / 2)); + + private readonly ConcurrentQueue _deferredCompressionQueue = new(); private volatile bool _disableDirectDownloads; private int _consecutiveDirectDownloadFailures; @@ -43,14 +46,16 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase FileCompactor fileCompactor, LightlessConfigService configService, TextureDownscaleService textureDownscaleService, + ModelDecimationService modelDecimationService, TextureMetadataHelper textureMetadataHelper) : base(logger, mediator) { - _downloadStatus = new Dictionary(StringComparer.Ordinal); + _downloadStatus = new ConcurrentDictionary(StringComparer.Ordinal); _orchestrator = orchestrator; _fileDbManager = fileCacheManager; _fileCompactor = fileCompactor; _configService = configService; _textureDownscaleService = textureDownscaleService; + _modelDecimationService = modelDecimationService; _textureMetadataHelper = textureMetadataHelper; _activeDownloadStreams = new(); _lastConfigDirectDownloadsState = _configService.Current.EnableDirectDownloads; @@ -84,19 +89,16 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase public void ClearDownload() { CurrentDownloads.Clear(); - lock (_downloadStatusLock) - { - _downloadStatus.Clear(); - } + _downloadStatus.Clear(); CurrentOwnerToken = null; } - public async Task DownloadFiles(GameObjectHandler? gameObject, List fileReplacementDto, CancellationToken ct, bool skipDownscale = false) + public async Task DownloadFiles(GameObjectHandler? gameObject, List fileReplacementDto, CancellationToken ct, bool skipDownscale = false, bool skipDecimation = false) { Mediator.Publish(new HaltScanMessage(nameof(DownloadFiles))); try { - await DownloadFilesInternal(gameObject, fileReplacementDto, ct, skipDownscale).ConfigureAwait(false); + await DownloadFilesInternal(gameObject, fileReplacementDto, ct, skipDownscale, skipDecimation).ConfigureAwait(false); } catch { @@ -154,29 +156,20 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase private void SetStatus(string key, DownloadStatus status) { - lock (_downloadStatusLock) - { - if (_downloadStatus.TryGetValue(key, out var st)) - st.DownloadStatus = status; - } + if (_downloadStatus.TryGetValue(key, out var st)) + st.DownloadStatus = status; } private void AddTransferredBytes(string key, long delta) { - lock (_downloadStatusLock) - { - if (_downloadStatus.TryGetValue(key, out var st)) - st.TransferredBytes += delta; - } + if (_downloadStatus.TryGetValue(key, out var st)) + st.AddTransferredBytes(delta); } private void MarkTransferredFiles(string key, int files) { - lock (_downloadStatusLock) - { - if (_downloadStatus.TryGetValue(key, out var st)) - st.TransferredFiles = files; - } + if (_downloadStatus.TryGetValue(key, out var st)) + st.SetTransferredFiles(files); } private static byte MungeByte(int byteOrEof) @@ -404,76 +397,32 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase private async Task WaitForDownloadReady(List downloadFileTransfer, Guid requestId, CancellationToken downloadCt) { - bool alreadyCancelled = false; - try + while (true) { - CancellationTokenSource localTimeoutCts = new(); - localTimeoutCts.CancelAfter(TimeSpan.FromSeconds(5)); - CancellationTokenSource composite = CancellationTokenSource.CreateLinkedTokenSource(downloadCt, localTimeoutCts.Token); + downloadCt.ThrowIfCancellationRequested(); - while (!_orchestrator.IsDownloadReady(requestId)) + if (_orchestrator.IsDownloadReady(requestId)) + break; + + using var resp = await _orchestrator.SendRequestAsync( + HttpMethod.Get, + LightlessFiles.RequestCheckQueueFullPath(downloadFileTransfer[0].DownloadUri, requestId), + downloadFileTransfer.Select(t => t.Hash).ToList(), + downloadCt).ConfigureAwait(false); + + resp.EnsureSuccessStatusCode(); + + var body = (await resp.Content.ReadAsStringAsync(downloadCt).ConfigureAwait(false)).Trim(); + if (string.Equals(body, "true", StringComparison.OrdinalIgnoreCase) || + body.Contains("\"ready\":true", StringComparison.OrdinalIgnoreCase)) { - try - { - await Task.Delay(250, composite.Token).ConfigureAwait(false); - } - catch (TaskCanceledException) - { - if (downloadCt.IsCancellationRequested) throw; - - var req = await _orchestrator.SendRequestAsync( - HttpMethod.Get, - LightlessFiles.RequestCheckQueueFullPath(downloadFileTransfer[0].DownloadUri, requestId), - downloadFileTransfer.Select(c => c.Hash).ToList(), - downloadCt).ConfigureAwait(false); - - req.EnsureSuccessStatusCode(); - - localTimeoutCts.Dispose(); - composite.Dispose(); - - localTimeoutCts = new(); - localTimeoutCts.CancelAfter(TimeSpan.FromSeconds(5)); - composite = CancellationTokenSource.CreateLinkedTokenSource(downloadCt, localTimeoutCts.Token); - } + break; } - localTimeoutCts.Dispose(); - composite.Dispose(); - - Logger.LogDebug("Download {requestId} ready", requestId); + await Task.Delay(250, downloadCt).ConfigureAwait(false); } - catch (TaskCanceledException) - { - try - { - await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCancelFullPath(downloadFileTransfer[0].DownloadUri, requestId)) - .ConfigureAwait(false); - alreadyCancelled = true; - } - catch - { - // ignore - } - throw; - } - finally - { - if (downloadCt.IsCancellationRequested && !alreadyCancelled) - { - try - { - await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCancelFullPath(downloadFileTransfer[0].DownloadUri, requestId)) - .ConfigureAwait(false); - } - catch - { - // ignore - } - } - _orchestrator.ClearDownloadRequest(requestId); - } + _orchestrator.ClearDownloadRequest(requestId); } private async Task DownloadQueuedBlockFileAsync( @@ -502,21 +451,15 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase } } - private void RemoveStatus(string key) - { - lock (_downloadStatusLock) - { - _downloadStatus.Remove(key); - } - } - private async Task DecompressBlockFileAsync( string downloadStatusKey, string blockFilePath, Dictionary replacementLookup, + IReadOnlyDictionary rawSizeLookup, string downloadLabel, CancellationToken ct, - bool skipDownscale) + bool skipDownscale, + bool skipDecimation) { SetStatus(downloadStatusKey, DownloadStatus.Decompressing); MarkTransferredFiles(downloadStatusKey, 1); @@ -532,29 +475,33 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase try { - // sanity check length if (fileLengthBytes < 0 || fileLengthBytes > int.MaxValue) throw new InvalidDataException($"Invalid block entry length: {fileLengthBytes}"); - // safe cast after check var len = checked((int)fileLengthBytes); if (!replacementLookup.TryGetValue(fileHash, out var repl)) { Logger.LogWarning("{dlName}: No replacement mapping for {fileHash}", downloadLabel, fileHash); - fileBlockStream.Seek(len, SeekOrigin.Current); + // still need to skip bytes: + var skip = checked((int)fileLengthBytes); + fileBlockStream.Position += skip; continue; } - // decompress var filePath = _fileDbManager.GetCacheFilePath(fileHash, repl.Extension); Logger.LogTrace("{dlName}: Decompressing {file}:{len} => {dest}", downloadLabel, fileHash, fileLengthBytes, filePath); - // read compressed data var compressed = new byte[len]; + await ReadExactlyAsync(fileBlockStream, compressed.AsMemory(0, len), ct).ConfigureAwait(false); - if (len == 0) + MungeBuffer(compressed); + var decompressed = LZ4Wrapper.Unwrap(compressed); + + if (rawSizeLookup.TryGetValue(fileHash, out var expectedRawSize) + && expectedRawSize > 0 + && decompressed.LongLength != expectedRawSize) { await _fileCompactor.WriteAllBytesAsync(filePath, Array.Empty(), ct).ConfigureAwait(false); PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale); @@ -563,21 +510,24 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase MungeBuffer(compressed); - // limit concurrent decompressions await _decompressGate.WaitAsync(ct).ConfigureAwait(false); try { - var sw = System.Diagnostics.Stopwatch.StartNew(); + // offload CPU-intensive decompression to threadpool to free up worker + await Task.Run(async () => + { + var sw = System.Diagnostics.Stopwatch.StartNew(); - // decompress - var decompressed = LZ4Wrapper.Unwrap(compressed); + // decompress + var decompressed = LZ4Wrapper.Unwrap(compressed); - Logger.LogTrace("{dlName}: Unwrap {fileHash} took {ms}ms (compressed {c} bytes, decompressed {d} bytes)", - downloadLabel, fileHash, sw.ElapsedMilliseconds, compressed.Length, decompressed?.Length ?? -1); + Logger.LogTrace("{dlName}: Unwrap {fileHash} took {ms}ms (compressed {c} bytes, decompressed {d} bytes)", + downloadLabel, fileHash, sw.ElapsedMilliseconds, compressed.Length, decompressed?.Length ?? -1); - // write to file - await _fileCompactor.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false); - PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale); + // write to file without compacting during download + await File.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false); + PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale); + }, ct).ConfigureAwait(false); } finally { @@ -594,6 +544,8 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase } } } + + SetStatus(downloadStatusKey, DownloadStatus.Completed); } catch (EndOfStreamException) { @@ -603,10 +555,6 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase { Logger.LogError(ex, "{dlName}: Error during block file read", downloadLabel); } - finally - { - RemoveStatus(downloadStatusKey); - } } public async Task> InitiateDownloadList( @@ -644,21 +592,25 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase .. await FilesGetSizes(hashes, ct).ConfigureAwait(false), ]; + Logger.LogDebug("Files with size 0 or less: {files}", + string.Join(", ", downloadFileInfoFromService.Where(f => f.Size <= 0).Select(f => f.Hash))); + foreach (var dto in downloadFileInfoFromService.Where(c => c.IsForbidden)) { if (!_orchestrator.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, dto.Hash, StringComparison.Ordinal))) _orchestrator.ForbiddenTransfers.Add(new DownloadFileTransfer(dto)); } - CurrentDownloads = [.. downloadFileInfoFromService + CurrentDownloads = downloadFileInfoFromService .Distinct() .Select(d => new DownloadFileTransfer(d)) - .Where(d => d.CanBeTransferred)]; + .Where(d => d.CanBeTransferred) + .ToList(); return CurrentDownloads; } - private sealed record BatchChunk(string Key, List Items); + private sealed record BatchChunk(string HostKey, string StatusKey, List Items); private static IEnumerable> ChunkList(List items, int chunkSize) { @@ -666,7 +618,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase yield return items.GetRange(i, Math.Min(chunkSize, items.Count - i)); } - private async Task DownloadFilesInternal(GameObjectHandler? gameObjectHandler, List fileReplacement, CancellationToken ct, bool skipDownscale) + private async Task DownloadFilesInternal(GameObjectHandler? gameObjectHandler, List fileReplacement, CancellationToken ct, bool skipDownscale, bool skipDecimation) { var objectName = gameObjectHandler?.Name ?? "Unknown"; @@ -684,6 +636,20 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase var allowDirectDownloads = ShouldUseDirectDownloads(); var replacementLookup = BuildReplacementLookup(fileReplacement); + var rawSizeLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var download in CurrentDownloads) + { + if (string.IsNullOrWhiteSpace(download.Hash)) + { + continue; + } + + if (!rawSizeLookup.TryGetValue(download.Hash, out var existing) || existing <= 0) + { + rawSizeLookup[download.Hash] = download.TotalRaw; + } + } var directDownloads = new List(); var batchDownloads = new List(); @@ -708,39 +674,36 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase var chunkSize = (int)Math.Ceiling(list.Count / (double)chunkCount); return ChunkList(list, chunkSize) - .Select(chunk => new BatchChunk(g.Key, chunk)); + .Select((chunk, index) => new BatchChunk(g.Key, $"{g.Key}#{index + 1}", chunk)); }) .ToArray(); // init statuses - lock (_downloadStatusLock) + _downloadStatus.Clear(); + + // direct downloads and batch downloads tracked separately + foreach (var d in directDownloads) { - _downloadStatus.Clear(); - - // direct downloads and batch downloads tracked separately - foreach (var d in directDownloads) + _downloadStatus[d.DirectDownloadUrl!] = new FileDownloadStatus { - _downloadStatus[d.DirectDownloadUrl!] = new FileDownloadStatus - { - DownloadStatus = DownloadStatus.Initializing, - TotalBytes = d.Total, - TotalFiles = 1, - TransferredBytes = 0, - TransferredFiles = 0 - }; - } + DownloadStatus = DownloadStatus.WaitingForSlot, + TotalBytes = d.Total, + TotalFiles = 1, + TransferredBytes = 0, + TransferredFiles = 0 + }; + } - foreach (var g in batchChunks.GroupBy(c => c.Key, StringComparer.Ordinal)) + foreach (var chunk in batchChunks) + { + _downloadStatus[chunk.StatusKey] = new FileDownloadStatus { - _downloadStatus[g.Key] = new FileDownloadStatus - { - DownloadStatus = DownloadStatus.Initializing, - TotalBytes = g.SelectMany(x => x.Items).Sum(x => x.Total), - TotalFiles = 1, - TransferredBytes = 0, - TransferredFiles = 0 - }; - } + DownloadStatus = DownloadStatus.WaitingForQueue, + TotalBytes = chunk.Items.Sum(x => x.Total), + TotalFiles = 1, + TransferredBytes = 0, + TransferredFiles = 0 + }; } if (directDownloads.Count > 0 || batchChunks.Length > 0) @@ -752,30 +715,47 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase if (gameObjectHandler is not null) Mediator.Publish(new DownloadStartedMessage(gameObjectHandler, _downloadStatus)); + // work based on cpu count and slots + var coreCount = Environment.ProcessorCount; + var baseWorkers = Math.Min(slots, coreCount); + + // only add buffer if decompression has capacity AND we have cores to spare + var availableDecompressSlots = _decompressGate.CurrentCount; + var extraWorkers = (availableDecompressSlots > 0 && coreCount >= 6) ? 2 : 0; + // allow some extra workers so downloads can continue while earlier items decompress. - var workerDop = Math.Clamp(slots * 2, 2, 16); + var workerDop = Math.Clamp(baseWorkers + extraWorkers, 2, coreCount); // batch downloads Task batchTask = batchChunks.Length == 0 ? Task.CompletedTask : Parallel.ForEachAsync(batchChunks, new ParallelOptions { MaxDegreeOfParallelism = workerDop, CancellationToken = ct }, - async (chunk, token) => await ProcessBatchChunkAsync(chunk, replacementLookup, token, skipDownscale).ConfigureAwait(false)); + async (chunk, token) => await ProcessBatchChunkAsync(chunk, replacementLookup, rawSizeLookup, token, skipDownscale, skipDecimation).ConfigureAwait(false)); // direct downloads Task directTask = directDownloads.Count == 0 ? Task.CompletedTask : Parallel.ForEachAsync(directDownloads, new ParallelOptions { MaxDegreeOfParallelism = workerDop, CancellationToken = ct }, - async (d, token) => await ProcessDirectAsync(d, replacementLookup, token, skipDownscale).ConfigureAwait(false)); + async (d, token) => await ProcessDirectAsync(d, replacementLookup, rawSizeLookup, token, skipDownscale, skipDecimation).ConfigureAwait(false)); await Task.WhenAll(batchTask, directTask).ConfigureAwait(false); + // process deferred compressions after all downloads complete + await ProcessDeferredCompressionsAsync(ct).ConfigureAwait(false); + Logger.LogDebug("Download end: {id}", objectName); ClearDownload(); } - private async Task ProcessBatchChunkAsync(BatchChunk chunk, Dictionary replacementLookup, CancellationToken ct, bool skipDownscale) + private async Task ProcessBatchChunkAsync( + BatchChunk chunk, + Dictionary replacementLookup, + IReadOnlyDictionary rawSizeLookup, + CancellationToken ct, + bool skipDownscale, + bool skipDecimation) { - var statusKey = chunk.Key; + var statusKey = chunk.StatusKey; // enqueue (no slot) SetStatus(statusKey, DownloadStatus.WaitingForQueue); @@ -793,7 +773,6 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase try { - // download (with slot) var progress = CreateInlineProgress(bytes => AddTransferredBytes(statusKey, bytes)); // Download slot held on get @@ -803,10 +782,11 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase if (!File.Exists(blockFile)) { Logger.LogWarning("{dlName}: Block file missing before extraction, skipping", fi.Name); + SetStatus(statusKey, DownloadStatus.Completed); return; } - await DecompressBlockFileAsync(statusKey, blockFile, replacementLookup, fi.Name, ct, skipDownscale).ConfigureAwait(false); + await DecompressBlockFileAsync(statusKey, blockFile, replacementLookup, rawSizeLookup, fi.Name, ct, skipDownscale, skipDecimation).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -823,7 +803,13 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase } } - private async Task ProcessDirectAsync(DownloadFileTransfer directDownload, Dictionary replacementLookup, CancellationToken ct, bool skipDownscale) + private async Task ProcessDirectAsync( + DownloadFileTransfer directDownload, + Dictionary replacementLookup, + IReadOnlyDictionary rawSizeLookup, + CancellationToken ct, + bool skipDownscale, + bool skipDecimation) { var progress = CreateInlineProgress(bytes => { @@ -833,7 +819,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase if (!ShouldUseDirectDownloads() || string.IsNullOrEmpty(directDownload.DirectDownloadUrl)) { - await ProcessDirectAsQueuedFallbackAsync(directDownload, replacementLookup, progress, ct, skipDownscale).ConfigureAwait(false); + await ProcessDirectAsQueuedFallbackAsync(directDownload, replacementLookup, rawSizeLookup, progress, ct, skipDownscale, skipDecimation).ConfigureAwait(false); return; } @@ -861,6 +847,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase if (!replacementLookup.TryGetValue(directDownload.Hash, out var repl)) { Logger.LogWarning("{hash}: No replacement data found for direct download.", directDownload.Hash); + SetStatus(directDownload.DirectDownloadUrl!, DownloadStatus.Completed); return; } @@ -873,13 +860,18 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase byte[] compressedBytes = await File.ReadAllBytesAsync(tempFilename, ct).ConfigureAwait(false); var decompressedBytes = LZ4Wrapper.Unwrap(compressedBytes); + if (directDownload.TotalRaw > 0 && decompressedBytes.LongLength != directDownload.TotalRaw) + { + throw new InvalidDataException( + $"{directDownload.Hash}: Decompressed size mismatch (expected {directDownload.TotalRaw}, got {decompressedBytes.LongLength})"); + } + await _fileCompactor.WriteAllBytesAsync(finalFilename, decompressedBytes, ct).ConfigureAwait(false); - PersistFileToStorage(directDownload.Hash, finalFilename, repl.GamePath, skipDownscale); + PersistFileToStorage(directDownload.Hash, finalFilename, repl.GamePath, skipDownscale, skipDecimation); MarkTransferredFiles(directDownload.DirectDownloadUrl!, 1); + SetStatus(directDownload.DirectDownloadUrl!, DownloadStatus.Completed); Logger.LogDebug("Finished direct download of {hash}.", directDownload.Hash); - - RemoveStatus(directDownload.DirectDownloadUrl!); } catch (OperationCanceledException ex) { @@ -902,7 +894,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase try { - await ProcessDirectAsQueuedFallbackAsync(directDownload, replacementLookup, progress, ct, skipDownscale).ConfigureAwait(false); + await ProcessDirectAsQueuedFallbackAsync(directDownload, replacementLookup, rawSizeLookup, progress, ct, skipDownscale, skipDecimation).ConfigureAwait(false); if (!expectedDirectDownloadFailure && failureCount >= 3 && !_disableDirectDownloads) { @@ -929,9 +921,11 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase private async Task ProcessDirectAsQueuedFallbackAsync( DownloadFileTransfer directDownload, Dictionary replacementLookup, + IReadOnlyDictionary rawSizeLookup, IProgress progress, CancellationToken ct, - bool skipDownscale) + bool skipDownscale, + bool skipDecimation) { if (string.IsNullOrEmpty(directDownload.DirectDownloadUrl)) throw new InvalidOperationException("Direct download fallback requested without a direct download URL."); @@ -956,7 +950,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase if (!File.Exists(blockFile)) throw new FileNotFoundException("Block file missing after direct download fallback.", blockFile); - await DecompressBlockFileAsync(statusKey, blockFile, replacementLookup, $"fallback-{directDownload.Hash}", ct, skipDownscale) + await DecompressBlockFileAsync(statusKey, blockFile, replacementLookup, rawSizeLookup, $"fallback-{directDownload.Hash}", ct, skipDownscale, skipDecimation) .ConfigureAwait(false); } finally @@ -974,18 +968,16 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase if (!_orchestrator.IsInitialized) throw new InvalidOperationException("FileTransferManager is not initialized"); - // batch request var response = await _orchestrator.SendRequestAsync( HttpMethod.Get, LightlessFiles.ServerFilesGetSizesFullPath(_orchestrator.FilesCdnUri!), hashes, ct).ConfigureAwait(false); - // ensure success return await response.Content.ReadFromJsonAsync>(cancellationToken: ct).ConfigureAwait(false) ?? []; } - private void PersistFileToStorage(string fileHash, string filePath, string gamePath, bool skipDownscale) + private void PersistFileToStorage(string fileHash, string filePath, string gamePath, bool skipDownscale, bool skipDecimation) { var fi = new FileInfo(filePath); @@ -1001,13 +993,26 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase fi.LastAccessTime = DateTime.Today; fi.LastWriteTime = RandomDayInThePast().Invoke(); + // queue file for deferred compression instead of compressing immediately + if (_configService.Current.UseCompactor) + _deferredCompressionQueue.Enqueue(filePath); + try { - var entry = _fileDbManager.CreateCacheEntry(filePath); - var mapKind = _textureMetadataHelper.DetermineMapKind(gamePath, filePath); + var entry = _fileDbManager.CreateCacheEntryWithKnownHash(filePath, fileHash); - if (!skipDownscale) - _textureDownscaleService.ScheduleDownscale(fileHash, filePath, mapKind); + if (!skipDownscale && _textureDownscaleService.ShouldScheduleDownscale(filePath)) + { + _textureDownscaleService.ScheduleDownscale( + fileHash, + filePath, + () => _textureMetadataHelper.DetermineMapKind(gamePath, filePath)); + } + + if (!skipDecimation && _modelDecimationService.ShouldScheduleDecimation(fileHash, filePath, gamePath)) + { + _modelDecimationService.ScheduleDecimation(fileHash, filePath, gamePath); + } if (entry != null && !string.Equals(entry.Hash, fileHash, StringComparison.OrdinalIgnoreCase)) { @@ -1026,6 +1031,52 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase private static IProgress CreateInlineProgress(Action callback) => new InlineProgress(callback); + private async Task ProcessDeferredCompressionsAsync(CancellationToken ct) + { + if (_deferredCompressionQueue.IsEmpty) + return; + + var filesToCompress = new List(); + while (_deferredCompressionQueue.TryDequeue(out var filePath)) + { + if (File.Exists(filePath)) + filesToCompress.Add(filePath); + } + + if (filesToCompress.Count == 0) + return; + + Logger.LogDebug("Starting deferred compression of {count} files", filesToCompress.Count); + + var compressionWorkers = Math.Clamp(Environment.ProcessorCount / 4, 2, 4); + + await Parallel.ForEachAsync(filesToCompress, + new ParallelOptions + { + MaxDegreeOfParallelism = compressionWorkers, + CancellationToken = ct + }, + async (filePath, token) => + { + try + { + await Task.Yield(); + if (_configService.Current.UseCompactor && File.Exists(filePath)) + { + var bytes = await File.ReadAllBytesAsync(filePath, token).ConfigureAwait(false); + await _fileCompactor.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false); + Logger.LogTrace("Compressed file: {filePath}", filePath); + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to compress file: {filePath}", filePath); + } + }).ConfigureAwait(false); + + Logger.LogDebug("Completed deferred compression of {count} files", filesToCompress.Count); + } + private sealed class InlineProgress : IProgress { private readonly Action _callback; diff --git a/LightlessSync/WebAPI/Files/Models/DownloadStatus.cs b/LightlessSync/WebAPI/Files/Models/DownloadStatus.cs index 6e10a73..3e210c8 100644 --- a/LightlessSync/WebAPI/Files/Models/DownloadStatus.cs +++ b/LightlessSync/WebAPI/Files/Models/DownloadStatus.cs @@ -6,5 +6,6 @@ public enum DownloadStatus WaitingForSlot, WaitingForQueue, Downloading, - Decompressing + Decompressing, + Completed } \ No newline at end of file diff --git a/LightlessSync/WebAPI/Files/Models/FileDownloadStatus.cs b/LightlessSync/WebAPI/Files/Models/FileDownloadStatus.cs index 9340278..aa0aed0 100644 --- a/LightlessSync/WebAPI/Files/Models/FileDownloadStatus.cs +++ b/LightlessSync/WebAPI/Files/Models/FileDownloadStatus.cs @@ -1,10 +1,46 @@ -namespace LightlessSync.WebAPI.Files.Models; +using System.Threading; + +namespace LightlessSync.WebAPI.Files.Models; public class FileDownloadStatus { - public DownloadStatus DownloadStatus { get; set; } - public long TotalBytes { get; set; } - public int TotalFiles { get; set; } - public long TransferredBytes { get; set; } - public int TransferredFiles { get; set; } -} \ No newline at end of file + private int _downloadStatus; + private long _totalBytes; + private int _totalFiles; + private long _transferredBytes; + private int _transferredFiles; + + public DownloadStatus DownloadStatus + { + get => (DownloadStatus)Volatile.Read(ref _downloadStatus); + set => Volatile.Write(ref _downloadStatus, (int)value); + } + + public long TotalBytes + { + get => Interlocked.Read(ref _totalBytes); + set => Interlocked.Exchange(ref _totalBytes, value); + } + + public int TotalFiles + { + get => Volatile.Read(ref _totalFiles); + set => Volatile.Write(ref _totalFiles, value); + } + + public long TransferredBytes + { + get => Interlocked.Read(ref _transferredBytes); + set => Interlocked.Exchange(ref _transferredBytes, value); + } + + public int TransferredFiles + { + get => Volatile.Read(ref _transferredFiles); + set => Volatile.Write(ref _transferredFiles, value); + } + + public void AddTransferredBytes(long delta) => Interlocked.Add(ref _transferredBytes, delta); + + public void SetTransferredFiles(int files) => Volatile.Write(ref _transferredFiles, files); +} -- 2.49.1 From c19db58eade0b99b1bb20cca6ed70b06e39c8be4 Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 5 Jan 2026 01:49:00 +0100 Subject: [PATCH 39/87] Fix build error from conflict --- LightlessSync/UI/CompactUI.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index 43f0c0b..2f173ca 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -52,12 +52,8 @@ public class CompactUi : WindowMediatorSubscriberBase private readonly LightlessConfigService _configService; private readonly LightlessMediator _lightlessMediator; private readonly PairLedger _pairLedger; - private readonly ConcurrentDictionary> _currentDownloads = new(); - private readonly DrawEntityFactory _drawEntityFactory; - private readonly FileUploadManager _fileTransferManager; private readonly PlayerPerformanceConfigService _playerPerformanceConfig; private readonly PairUiService _pairUiService; - private readonly PlayerPerformanceConfigService _playerPerformanceConfig; private readonly ServerConfigurationManager _serverManager; private readonly TagHandler _tagHandler; private readonly UiSharedService _uiSharedService; -- 2.49.1 From 39d5d9d7c1bf1a0b093d843356997611ba914422 Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 5 Jan 2026 01:54:19 +0100 Subject: [PATCH 40/87] Another few fixes. --- LightlessSync/PlayerData/Factories/PlayerDataFactory.cs | 8 -------- LightlessSync/Plugin.cs | 1 - LightlessSync/UI/CompactUI.cs | 2 +- LightlessSync/WebAPI/Files/FileDownloadManager.cs | 4 ++-- 4 files changed, 3 insertions(+), 12 deletions(-) diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index 5c5b580..45b7e01 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -169,11 +169,6 @@ public class PlayerDataFactory using var cts = new CancellationTokenSource(_hardBuildTimeout); var fragment = await CreateCharacterDataInternal(obj, cts.Token).ConfigureAwait(false); - fragment.FileReplacements = - new HashSet(resolvedPaths.Select(c => new FileReplacement([.. c.Value], c.Key)), FileReplacementComparer.Instance) - .Where(p => p.HasFileReplacement).ToHashSet(); - var allowedExtensions = CacheMonitor.AllowedFileExtensions; - fragment.FileReplacements.RemoveWhere(c => c.GamePaths.Any(g => !allowedExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase)))); _characterBuildCache[key] = new CacheEntry(fragment, DateTime.UtcNow); PruneCharacterCacheIfNeeded(); @@ -220,9 +215,6 @@ public class PlayerDataFactory // get all remaining paths and resolve them var transientPaths = ManageSemiTransientData(objectKind); - var resolvedTransientPaths = transientPaths.Count == 0 - ? new Dictionary(StringComparer.OrdinalIgnoreCase).AsReadOnly() - : await GetFileReplacementsFromPaths(playerRelatedObject, transientPaths, new HashSet(StringComparer.Ordinal)).ConfigureAwait(false); ct.ThrowIfCancellationRequested(); if (await CheckForNullDrawObject(playerRelatedObject.Address).ConfigureAwait(false)) diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 88382c6..4e1ed4e 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -123,7 +123,6 @@ public sealed class Plugin : IDalamudPlugin services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index 2f173ca..a27c568 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -167,7 +167,7 @@ public class CompactUi : WindowMediatorSubscriberBase Mediator.Subscribe(this, (_) => IsOpen = false); Mediator.Subscribe(this, (_) => UiSharedService_GposeStart()); Mediator.Subscribe(this, (_) => UiSharedService_GposeEnd()); - Mediator.Subscribe(this, (msg) => _currentDownloads[msg.DownloadId] = msg.DownloadStatus); + Mediator.Subscribe(this, (msg) => _currentDownloads[msg.DownloadId] = (Dictionary)msg.DownloadStatus); Mediator.Subscribe(this, (msg) => _currentDownloads.TryRemove(msg.DownloadId, out _)); Mediator.Subscribe(this, (msg) => _drawFolders = DrawFolders.ToList()); diff --git a/LightlessSync/WebAPI/Files/FileDownloadManager.cs b/LightlessSync/WebAPI/Files/FileDownloadManager.cs index 2013b50..97f8af7 100644 --- a/LightlessSync/WebAPI/Files/FileDownloadManager.cs +++ b/LightlessSync/WebAPI/Files/FileDownloadManager.cs @@ -504,7 +504,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase && decompressed.LongLength != expectedRawSize) { await _fileCompactor.WriteAllBytesAsync(filePath, Array.Empty(), ct).ConfigureAwait(false); - PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale); + PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation); continue; } @@ -526,7 +526,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase // write to file without compacting during download await File.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false); - PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale); + PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation); }, ct).ConfigureAwait(false); } finally -- 2.49.1 From 5fc13647ae45ccc188622f0b34795b0781472094 Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 5 Jan 2026 14:24:07 +0100 Subject: [PATCH 41/87] Fixed name getting, cast fix on compact ui --- .../PlayerData/Factories/PlayerDataFactory.cs | 1 - .../PlayerData/Handlers/GameObjectHandler.cs | 27 +++++++++++-------- LightlessSync/UI/CompactUI.cs | 7 +++-- LightlessSync/UI/DownloadUi.cs | 24 +++++++---------- 4 files changed, 30 insertions(+), 29 deletions(-) diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index 45b7e01..99b9371 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -214,7 +214,6 @@ public class PlayerDataFactory .ConfigureAwait(false); // get all remaining paths and resolve them - var transientPaths = ManageSemiTransientData(objectKind); ct.ThrowIfCancellationRequested(); if (await CheckForNullDrawObject(playerRelatedObject.Address).ConfigureAwait(false)) diff --git a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs index c90096d..f3d1a4b 100644 --- a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs +++ b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs @@ -78,6 +78,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP if (msg.Address == Address) { _haltProcessing = false; + Refresh(); } }); @@ -176,30 +177,36 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP Mediator.Publish(new GameObjectHandlerDestroyedMessage(this, _isOwnedObject)); } - private unsafe void CheckAndUpdateObject() + private void CheckAndUpdateObject() => CheckAndUpdateObject(allowPublish: true); + + private unsafe void CheckAndUpdateObject(bool allowPublish = true) { var prevAddr = Address; var prevDrawObj = DrawObjectAddress; Address = _getAddress(); + if (Address != IntPtr.Zero) { var gameObject = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address; - var drawObjAddr = (IntPtr)gameObject->DrawObject; - DrawObjectAddress = drawObjAddr; + DrawObjectAddress = (IntPtr)gameObject->DrawObject; EntityId = gameObject->EntityId; - CurrentDrawCondition = DrawCondition.None; + + var chara = (Character*)Address; + var newName = chara->GameObject.NameString; + + if (!string.IsNullOrEmpty(newName) && !string.Equals(newName, Name, StringComparison.Ordinal)) + Name = newName; } else { DrawObjectAddress = IntPtr.Zero; EntityId = uint.MaxValue; - CurrentDrawCondition = DrawCondition.DrawObjectZero; } CurrentDrawCondition = IsBeingDrawnUnsafe(); - if (_haltProcessing) return; + if (_haltProcessing || !allowPublish) return; bool drawObjDiff = DrawObjectAddress != prevDrawObj; bool addrDiff = Address != prevAddr; @@ -356,12 +363,10 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP private void FrameworkUpdate() { - if (!_delayedZoningTask?.IsCompleted ?? false) return; - try { - _performanceCollector.LogPerformance(this, $"CheckAndUpdateObject>{(_isOwnedObject ? "Self" : "Other")}+{ObjectKind}/{(string.IsNullOrEmpty(Name) ? "Unk" : Name)}" - + $"+{Address.ToString("X")}", CheckAndUpdateObject); + var zoningDelayActive = !(_delayedZoningTask?.IsCompleted ?? true); + _performanceCollector.LogPerformance(this, $"CheckAndUpdateObject>{(_isOwnedObject ? "Self" : "Other")}+{ObjectKind}/{(string.IsNullOrEmpty(Name) ? "Unk" : Name)}", () => CheckAndUpdateObject(allowPublish: !zoningDelayActive)); } catch (Exception ex) { @@ -462,6 +467,6 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP Logger.LogDebug("[{this}] Delay after zoning complete", this); _zoningCts.Dispose(); } - }); + }, _zoningCts.Token); } } \ No newline at end of file diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index a27c568..a43f228 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -167,9 +167,12 @@ public class CompactUi : WindowMediatorSubscriberBase Mediator.Subscribe(this, (_) => IsOpen = false); Mediator.Subscribe(this, (_) => UiSharedService_GposeStart()); Mediator.Subscribe(this, (_) => UiSharedService_GposeEnd()); - Mediator.Subscribe(this, (msg) => _currentDownloads[msg.DownloadId] = (Dictionary)msg.DownloadStatus); + Mediator.Subscribe(this, msg => + { + _currentDownloads[msg.DownloadId] = new Dictionary(msg.DownloadStatus, StringComparer.Ordinal); + }); Mediator.Subscribe(this, (msg) => _currentDownloads.TryRemove(msg.DownloadId, out _)); - Mediator.Subscribe(this, (msg) => _drawFolders = DrawFolders.ToList()); + Mediator.Subscribe(this, (msg) => _drawFolders = [.. DrawFolders]); _characterAnalyzer = characterAnalyzer; _playerPerformanceConfig = playerPerformanceConfig; diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index 7ed5629..6cc4bd1 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -65,12 +65,14 @@ public class DownloadUi : WindowMediatorSubscriberBase IsOpen = true; - Mediator.Subscribe(this, (msg) => + Mediator.Subscribe(this, msg => { _currentDownloads[msg.DownloadId] = msg.DownloadStatus; - // Capture initial totals when download starts - var totalFiles = msg.DownloadStatus.Values.Sum(s => s.TotalFiles); - var totalBytes = msg.DownloadStatus.Values.Sum(s => s.TotalBytes); + + var snap = msg.DownloadStatus.ToArray(); + var totalFiles = snap.Sum(kv => kv.Value?.TotalFiles ?? 0); + var totalBytes = snap.Sum(kv => kv.Value?.TotalBytes ?? 0); + _downloadInitialTotals[msg.DownloadId] = (totalFiles, totalBytes); _notificationDismissed = false; }); @@ -79,7 +81,7 @@ public class DownloadUi : WindowMediatorSubscriberBase _currentDownloads.TryRemove(msg.DownloadId, out _); // Dismiss notification if all downloads are complete - if (!_currentDownloads.Any() && !_notificationDismissed) + if (_currentDownloads.IsEmpty && !_notificationDismissed) { Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress")); _notificationDismissed = true; @@ -474,7 +476,7 @@ public class DownloadUi : WindowMediatorSubscriberBase totalBytes += playerTotalBytes; transferredBytes += playerTransferredBytes; - // per-player W/Q/P/D + // per-player W/Q/P/D/C var playerDlSlot = 0; var playerDlQueue = 0; var playerDlProg = 0; @@ -487,6 +489,7 @@ public class DownloadUi : WindowMediatorSubscriberBase switch (fileStatus.DownloadStatus) { case DownloadStatus.Initializing: + case DownloadStatus.WaitingForQueue: playerDlQueue++; totalDlQueue++; break; @@ -494,10 +497,6 @@ public class DownloadUi : WindowMediatorSubscriberBase playerDlSlot++; totalDlSlot++; break; - case DownloadStatus.WaitingForQueue: - playerDlQueue++; - totalDlQueue++; - break; case DownloadStatus.Downloading: playerDlProg++; totalDlProg++; @@ -550,11 +549,6 @@ public class DownloadUi : WindowMediatorSubscriberBase if (totalFiles == 0 || totalBytes == 0) return; - // max speed for per-player bar scale (clamped) - double maxSpeed = perPlayer.Count > 0 ? perPlayer.Max(p => p.SpeedBytesPerSecond) : 0; - if (maxSpeed <= 0) - maxSpeed = 1; - var drawList = ImGui.GetBackgroundDrawList(); var windowPos = ImGui.GetWindowPos(); -- 2.49.1 From d16e46200d918efe0f39e12a95d6fb93e88feefa Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 5 Jan 2026 16:41:30 +0100 Subject: [PATCH 42/87] Added clear of block of pap files. --- .../PlayerData/Pairs/PairHandlerAdapter.cs | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index 82f4749..fd1db53 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -25,7 +25,6 @@ using Microsoft.Extensions.Logging; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind; using FileReplacementDataComparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer; -using LightlessSync.LightlessConfiguration; namespace LightlessSync.PlayerData.Pairs; @@ -96,6 +95,9 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private readonly Dictionary> _pendingOwnedChanges = new(); private CancellationTokenSource? _ownedRetryCts; private Task _ownedRetryTask = Task.CompletedTask; + + + private static readonly TimeSpan OwnedRetryInitialDelay = TimeSpan.FromSeconds(1); private static readonly TimeSpan OwnedRetryMaxDelay = TimeSpan.FromSeconds(10); private static readonly TimeSpan OwnedRetryStaleDataGrace = TimeSpan.FromMinutes(5); @@ -109,6 +111,9 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa }; private readonly ConcurrentDictionary _blockedPapHashes = new(StringComparer.OrdinalIgnoreCase); + private AnimationValidationMode _lastAnimMode = (AnimationValidationMode)(-1); + private bool _lastAllowOneBasedShift; + private bool _lastAllowNeighborTolerance; private readonly ConcurrentDictionary _dumpedRemoteSkeletonForHash = new(StringComparer.OrdinalIgnoreCase); private DateTime? _invisibleSinceUtc; @@ -2455,6 +2460,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa try { + RefreshPapBlockCacheIfAnimSettingsChanged(); + var replacementList = charaData.FileReplacements.SelectMany(k => k.Value.Where(v => string.IsNullOrEmpty(v.FileSwapPath))).ToList(); Parallel.ForEach(replacementList, new ParallelOptions() { @@ -2855,6 +2862,26 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } } + private void RefreshPapBlockCacheIfAnimSettingsChanged() + { + var cfg = _configService.Current; + + if (cfg.AnimationValidationMode != _lastAnimMode + || cfg.AnimationAllowOneBasedShift != _lastAllowOneBasedShift + || cfg.AnimationAllowNeighborIndexTolerance != _lastAllowNeighborTolerance) + { + _lastAnimMode = cfg.AnimationValidationMode; + _lastAllowOneBasedShift = cfg.AnimationAllowOneBasedShift; + _lastAllowNeighborTolerance = cfg.AnimationAllowNeighborIndexTolerance; + + _blockedPapHashes.Clear(); + _dumpedRemoteSkeletonForHash.Clear(); + + Logger.LogDebug("{handler}: Cleared blocked PAP cache due to animation setting change (mode={mode}, shift={shift}, neigh={neigh})", + GetLogIdentifier(), _lastAnimMode, _lastAllowOneBasedShift, _lastAllowNeighborTolerance); + } + } + private static void SplitPapMappings( Dictionary<(string GamePath, string? Hash), string> moddedPaths, out Dictionary<(string GamePath, string? Hash), string> withoutPap, @@ -2879,6 +2906,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Dictionary<(string GamePath, string? Hash), string> papOnly, CancellationToken token) { + RefreshPapBlockCacheIfAnimSettingsChanged(); + var mode = _configService.Current.AnimationValidationMode; var allowBasedShift = _configService.Current.AnimationAllowOneBasedShift; var allownNightIndex = _configService.Current.AnimationAllowNeighborIndexTolerance; -- 2.49.1 From 3205e6e0c33678ede5b5f3abce83928daa0daf4e Mon Sep 17 00:00:00 2001 From: defnotken Date: Mon, 5 Jan 2026 10:40:31 -0600 Subject: [PATCH 43/87] Adding AccessViolationException catch to return true for NullDrawObject --- .../PlayerData/Factories/PlayerDataFactory.cs | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index 99b9371..16dcaff 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -123,16 +123,22 @@ public class PlayerDataFactory { if (playerPointer == IntPtr.Zero) return true; + try + { + var character = (Character*)playerPointer; + if (character == null) + return true; - var character = (Character*)playerPointer; - if (character == null) + var gameObject = &character->GameObject; + if (gameObject == null) + return true; + + return gameObject->DrawObject == null; + } + catch (AccessViolationException) + { return true; - - var gameObject = &character->GameObject; - if (gameObject == null) - return true; - - return gameObject->DrawObject == null; + } } private static bool IsCacheFresh(CacheEntry entry) -- 2.49.1 From 8e08da747145b86a3853e448a9b83dfb65e0fa4c Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 5 Jan 2026 19:58:10 +0000 Subject: [PATCH 44/87] Chat changes for 2.0.3 (#134) Co-authored-by: azyges Co-authored-by: cake Reviewed-on: https://git.lightless-sync.org/Lightless-Sync/LightlessClient/pulls/134 Reviewed-by: defnotken --- .../Configurations/ChatConfig.cs | 1 + .../PlayerData/Factories/PlayerDataFactory.cs | 2 +- .../Services/Chat/ChatEmoteService.cs | 579 ++++++++++++++++-- .../LightFinder/LightFinderScannerService.cs | 6 +- LightlessSync/UI/ZoneChatUi.cs | 520 ++++++++++++---- 5 files changed, 942 insertions(+), 166 deletions(-) diff --git a/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs index 43090a2..5532d78 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs @@ -11,6 +11,7 @@ public sealed class ChatConfig : ILightlessConfiguration public bool ShowRulesOverlayOnOpen { get; set; } = true; public bool ShowMessageTimestamps { get; set; } = true; public bool ShowNotesInSyncshellChat { get; set; } = true; + public bool EnableAnimatedEmotes { get; set; } = true; public float ChatWindowOpacity { get; set; } = .97f; public bool FadeWhenUnfocused { get; set; } = false; public float UnfocusedWindowOpacity { get; set; } = 0.6f; diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index 16dcaff..7b76953 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -669,7 +669,7 @@ public class PlayerDataFactory list.Add(forwardPaths[i].ToLowerInvariant()); else { - resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()]; + resolvedPaths[filePath] = [forwardPathsLower[i]]; } } diff --git a/LightlessSync/Services/Chat/ChatEmoteService.cs b/LightlessSync/Services/Chat/ChatEmoteService.cs index b733f2e..e0d402f 100644 --- a/LightlessSync/Services/Chat/ChatEmoteService.cs +++ b/LightlessSync/Services/Chat/ChatEmoteService.cs @@ -1,29 +1,41 @@ using Dalamud.Interface.Textures.TextureWraps; +using LightlessSync.LightlessConfiguration; using LightlessSync.UI; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; +using System.Diagnostics; using System.Text.Json; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Gif; +using SixLabors.ImageSharp.Formats.Webp; +using SixLabors.ImageSharp.Metadata; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; namespace LightlessSync.Services.Chat; public sealed class ChatEmoteService : IDisposable { private const string GlobalEmoteSetUrl = "https://7tv.io/v3/emote-sets/global"; + private const int DefaultFrameDelayMs = 100; + private const int MinFrameDelayMs = 20; private readonly ILogger _logger; private readonly HttpClient _httpClient; private readonly UiSharedService _uiSharedService; + private readonly ChatConfigService _chatConfigService; private readonly ConcurrentDictionary _emotes = new(StringComparer.Ordinal); private readonly SemaphoreSlim _downloadGate = new(3, 3); private readonly object _loadLock = new(); private Task? _loadTask; - public ChatEmoteService(ILogger logger, HttpClient httpClient, UiSharedService uiSharedService) + public ChatEmoteService(ILogger logger, HttpClient httpClient, UiSharedService uiSharedService, ChatConfigService chatConfigService) { _logger = logger; _httpClient = httpClient; _uiSharedService = uiSharedService; + _chatConfigService = chatConfigService; } public void EnsureGlobalEmotesLoaded() @@ -62,13 +74,17 @@ public sealed class ChatEmoteService : IDisposable return false; } - if (entry.Texture is not null) + var allowAnimation = _chatConfigService.Current.EnableAnimatedEmotes; + if (entry.TryGetTexture(allowAnimation, out texture)) { - texture = entry.Texture; + if (allowAnimation && entry.NeedsAnimationLoad && !entry.HasAttemptedAnimation) + { + entry.EnsureLoading(allowAnimation, QueueEmoteDownload, allowWhenStaticLoaded: true); + } return true; } - entry.EnsureLoading(QueueEmoteDownload); + entry.EnsureLoading(allowAnimation, QueueEmoteDownload); return true; } @@ -76,7 +92,7 @@ public sealed class ChatEmoteService : IDisposable { foreach (var entry in _emotes.Values) { - entry.Texture?.Dispose(); + entry.Dispose(); } _downloadGate.Dispose(); @@ -108,13 +124,13 @@ public sealed class ChatEmoteService : IDisposable continue; } - var url = TryBuildEmoteUrl(emoteElement); - if (string.IsNullOrWhiteSpace(url)) + var source = TryBuildEmoteSource(emoteElement); + if (source is null || (!source.Value.HasStatic && !source.Value.HasAnimation)) { continue; } - _emotes.TryAdd(name, new EmoteEntry(url)); + _emotes.TryAdd(name, new EmoteEntry(name, source.Value)); } } catch (Exception ex) @@ -123,7 +139,7 @@ public sealed class ChatEmoteService : IDisposable } } - private static string? TryBuildEmoteUrl(JsonElement emoteElement) + private static EmoteSource? TryBuildEmoteSource(JsonElement emoteElement) { if (!emoteElement.TryGetProperty("data", out var dataElement)) { @@ -156,29 +172,38 @@ public sealed class ChatEmoteService : IDisposable return null; } - var fileName = PickBestStaticFile(filesElement); - if (string.IsNullOrWhiteSpace(fileName)) + var files = ReadEmoteFiles(filesElement); + if (files.Count == 0) { return null; } - return baseUrl.TrimEnd('/') + "/" + fileName; + var animatedFile = PickBestAnimatedFile(files); + var animatedUrl = animatedFile is null ? null : BuildEmoteUrl(baseUrl, animatedFile.Value.Name); + + var staticName = animatedFile?.StaticName; + if (string.IsNullOrWhiteSpace(staticName)) + { + staticName = PickBestStaticFileName(files); + } + + var staticUrl = string.IsNullOrWhiteSpace(staticName) ? null : BuildEmoteUrl(baseUrl, staticName); + if (string.IsNullOrWhiteSpace(animatedUrl) && string.IsNullOrWhiteSpace(staticUrl)) + { + return null; + } + + return new EmoteSource(staticUrl, animatedUrl); } - private static string? PickBestStaticFile(JsonElement filesElement) - { - string? png1x = null; - string? webp1x = null; - string? pngFallback = null; - string? webpFallback = null; + private static string BuildEmoteUrl(string baseUrl, string fileName) + => baseUrl.TrimEnd('/') + "/" + fileName; + private static List ReadEmoteFiles(JsonElement filesElement) + { + var files = new List(); foreach (var file in filesElement.EnumerateArray()) { - if (file.TryGetProperty("static", out var staticElement) && staticElement.ValueKind == JsonValueKind.False) - { - continue; - } - if (!file.TryGetProperty("name", out var nameElement)) { continue; @@ -190,6 +215,88 @@ public sealed class ChatEmoteService : IDisposable continue; } + string? staticName = null; + if (file.TryGetProperty("static_name", out var staticNameElement) && staticNameElement.ValueKind == JsonValueKind.String) + { + staticName = staticNameElement.GetString(); + } + + var frameCount = 1; + if (file.TryGetProperty("frame_count", out var frameCountElement) && frameCountElement.ValueKind == JsonValueKind.Number) + { + frameCountElement.TryGetInt32(out frameCount); + frameCount = Math.Max(frameCount, 1); + } + + string? format = null; + if (file.TryGetProperty("format", out var formatElement) && formatElement.ValueKind == JsonValueKind.String) + { + format = formatElement.GetString(); + } + + files.Add(new EmoteFile(name, staticName, frameCount, format)); + } + + return files; + } + + private static EmoteFile? PickBestAnimatedFile(IReadOnlyList files) + { + EmoteFile? webp1x = null; + EmoteFile? gif1x = null; + EmoteFile? webpFallback = null; + EmoteFile? gifFallback = null; + + foreach (var file in files) + { + if (file.FrameCount <= 1 || !IsAnimatedFormatSupported(file)) + { + continue; + } + + if (file.Name.Equals("1x.webp", StringComparison.OrdinalIgnoreCase)) + { + webp1x = file; + } + else if (file.Name.Equals("1x.gif", StringComparison.OrdinalIgnoreCase)) + { + gif1x = file; + } + else if (file.Name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) && webpFallback is null) + { + webpFallback = file; + } + else if (file.Name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) && gifFallback is null) + { + gifFallback = file; + } + } + + return webp1x ?? gif1x ?? webpFallback ?? gifFallback; + } + + private static string? PickBestStaticFileName(IReadOnlyList files) + { + string? png1x = null; + string? webp1x = null; + string? gif1x = null; + string? pngFallback = null; + string? webpFallback = null; + string? gifFallback = null; + + foreach (var file in files) + { + if (file.FrameCount > 1) + { + continue; + } + + var name = file.StaticName ?? file.Name; + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + if (name.Equals("1x.png", StringComparison.OrdinalIgnoreCase)) { png1x = name; @@ -198,6 +305,10 @@ public sealed class ChatEmoteService : IDisposable { webp1x = name; } + else if (name.Equals("1x.gif", StringComparison.OrdinalIgnoreCase)) + { + gif1x = name; + } else if (name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) && pngFallback is null) { pngFallback = name; @@ -206,25 +317,80 @@ public sealed class ChatEmoteService : IDisposable { webpFallback = name; } + else if (name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) && gifFallback is null) + { + gifFallback = name; + } } - return png1x ?? webp1x ?? pngFallback ?? webpFallback; + return png1x ?? webp1x ?? gif1x ?? pngFallback ?? webpFallback ?? gifFallback; } - private void QueueEmoteDownload(EmoteEntry entry) + private static bool IsAnimatedFormatSupported(EmoteFile file) + { + if (!string.IsNullOrWhiteSpace(file.Format)) + { + return file.Format.Equals("WEBP", StringComparison.OrdinalIgnoreCase) + || file.Format.Equals("GIF", StringComparison.OrdinalIgnoreCase); + } + + return file.Name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) + || file.Name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase); + } + + private readonly record struct EmoteSource(string? StaticUrl, string? AnimatedUrl) + { + public bool HasStatic => !string.IsNullOrWhiteSpace(StaticUrl); + public bool HasAnimation => !string.IsNullOrWhiteSpace(AnimatedUrl); + } + + private readonly record struct EmoteFile(string Name, string? StaticName, int FrameCount, string? Format); + + private void QueueEmoteDownload(EmoteEntry entry, bool allowAnimation) { _ = Task.Run(async () => { await _downloadGate.WaitAsync().ConfigureAwait(false); try { - var data = await _httpClient.GetByteArrayAsync(entry.Url).ConfigureAwait(false); - var texture = _uiSharedService.LoadImage(data); - entry.SetTexture(texture); + if (allowAnimation) + { + if (entry.HasAnimatedSource) + { + entry.MarkAnimationAttempted(); + if (await TryLoadAnimatedEmoteAsync(entry).ConfigureAwait(false)) + { + return; + } + } + + if (entry.HasStaticSource && !entry.HasStaticTexture && await TryLoadStaticEmoteAsync(entry).ConfigureAwait(false)) + { + return; + } + } + else + { + if (entry.HasStaticSource && await TryLoadStaticEmoteAsync(entry).ConfigureAwait(false)) + { + return; + } + + if (entry.HasAnimatedSource) + { + entry.MarkAnimationAttempted(); + if (await TryLoadAnimatedEmoteAsync(entry).ConfigureAwait(false)) + { + return; + } + } + } + + entry.MarkFailed(); } catch (Exception ex) { - _logger.LogDebug(ex, "Failed to load 7TV emote {Url}", entry.Url); + _logger.LogDebug(ex, "Failed to load 7TV emote {Emote}", entry.Code); entry.MarkFailed(); } finally @@ -234,21 +400,334 @@ public sealed class ChatEmoteService : IDisposable }); } - private sealed class EmoteEntry + private async Task TryLoadAnimatedEmoteAsync(EmoteEntry entry) { - private int _loadingState; - - public EmoteEntry(string url) + if (string.IsNullOrWhiteSpace(entry.AnimatedUrl)) { - Url = url; + return false; } - public string Url { get; } - public IDalamudTextureWrap? Texture { get; private set; } - - public void EnsureLoading(Action queueDownload) + try { - if (Texture is not null) + var data = await _httpClient.GetByteArrayAsync(entry.AnimatedUrl).ConfigureAwait(false); + var isWebp = entry.AnimatedUrl.EndsWith(".webp", StringComparison.OrdinalIgnoreCase); + if (!TryDecodeAnimation(data, isWebp, out var animation)) + { + return false; + } + + entry.SetAnimation(animation); + return true; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to decode animated 7TV emote {Emote}", entry.Code); + return false; + } + } + + private async Task TryLoadStaticEmoteAsync(EmoteEntry entry) + { + if (string.IsNullOrWhiteSpace(entry.StaticUrl)) + { + return false; + } + + try + { + var data = await _httpClient.GetByteArrayAsync(entry.StaticUrl).ConfigureAwait(false); + var texture = _uiSharedService.LoadImage(data); + entry.SetStaticTexture(texture); + return true; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to decode static 7TV emote {Emote}", entry.Code); + return false; + } + } + + private bool TryDecodeAnimation(byte[] data, bool isWebp, out EmoteAnimation? animation) + { + animation = null; + List? frames = null; + + try + { + Image image; + if (isWebp) + { + using var stream = new MemoryStream(data); + image = WebpDecoder.Instance.Decode( + new WebpDecoderOptions { BackgroundColorHandling = BackgroundColorHandling.Ignore }, + stream); + } + else + { + image = Image.Load(data); + } + + using (image) + { + if (image.Frames.Count <= 1) + { + return false; + } + + using var composite = new Image(image.Width, image.Height, Color.Transparent); + Image? restoreCanvas = null; + GifDisposalMethod? pendingGifDisposal = null; + WebpDisposalMethod? pendingWebpDisposal = null; + + frames = new List(image.Frames.Count); + for (var i = 0; i < image.Frames.Count; i++) + { + var frameMetadata = image.Frames[i].Metadata; + var delayMs = GetFrameDelayMs(frameMetadata); + + ApplyDisposal(composite, ref restoreCanvas, pendingGifDisposal, pendingWebpDisposal); + + GifDisposalMethod? currentGifDisposal = null; + WebpDisposalMethod? currentWebpDisposal = null; + var blendMethod = WebpBlendMethod.Over; + + if (isWebp) + { + if (frameMetadata.TryGetWebpFrameMetadata(out var webpMetadata)) + { + currentWebpDisposal = webpMetadata.DisposalMethod; + blendMethod = webpMetadata.BlendMethod; + } + } + else if (frameMetadata.TryGetGifMetadata(out var gifMetadata)) + { + currentGifDisposal = gifMetadata.DisposalMethod; + } + + if (currentGifDisposal == GifDisposalMethod.RestoreToPrevious) + { + restoreCanvas?.Dispose(); + restoreCanvas = composite.Clone(); + } + + using var frameImage = image.Frames.CloneFrame(i); + var alphaMode = blendMethod == WebpBlendMethod.Source + ? PixelAlphaCompositionMode.Src + : PixelAlphaCompositionMode.SrcOver; + composite.Mutate(ctx => ctx.DrawImage(frameImage, PixelColorBlendingMode.Normal, alphaMode, 1f)); + + using var renderedFrame = composite.Clone(); + using var ms = new MemoryStream(); + renderedFrame.SaveAsPng(ms); + + var texture = _uiSharedService.LoadImage(ms.ToArray()); + frames.Add(new EmoteFrame(texture, delayMs)); + + pendingGifDisposal = currentGifDisposal; + pendingWebpDisposal = currentWebpDisposal; + } + + restoreCanvas?.Dispose(); + + animation = new EmoteAnimation(frames); + return true; + } + } + catch + { + if (frames is not null) + { + foreach (var frame in frames) + { + frame.Texture.Dispose(); + } + } + + return false; + } + } + + private static int GetFrameDelayMs(ImageFrameMetadata metadata) + { + if (metadata.TryGetGifMetadata(out var gifMetadata)) + { + var delayMs = (long)gifMetadata.FrameDelay * 10L; + return NormalizeFrameDelayMs(delayMs); + } + + if (metadata.TryGetWebpFrameMetadata(out var webpMetadata)) + { + return NormalizeFrameDelayMs(webpMetadata.FrameDelay); + } + + return DefaultFrameDelayMs; + } + + private static int NormalizeFrameDelayMs(long delayMs) + { + if (delayMs <= 0) + { + return DefaultFrameDelayMs; + } + + var clamped = delayMs > int.MaxValue ? int.MaxValue : (int)delayMs; + return Math.Max(clamped, MinFrameDelayMs); + } + + private static void ApplyDisposal( + Image composite, + ref Image? restoreCanvas, + GifDisposalMethod? gifDisposal, + WebpDisposalMethod? webpDisposal) + { + if (gifDisposal is not null) + { + switch (gifDisposal) + { + case GifDisposalMethod.RestoreToBackground: + composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent)); + break; + case GifDisposalMethod.RestoreToPrevious: + if (restoreCanvas is not null) + { + composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent)); + var restoreSnapshot = restoreCanvas; + composite.Mutate(ctx => ctx.DrawImage(restoreSnapshot, PixelColorBlendingMode.Normal, PixelAlphaCompositionMode.Src, 1f)); + restoreCanvas.Dispose(); + restoreCanvas = null; + } + break; + } + } + else if (webpDisposal == WebpDisposalMethod.RestoreToBackground) + { + composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent)); + } + } + + private sealed class EmoteAnimation : IDisposable + { + private readonly EmoteFrame[] _frames; + private readonly int _durationMs; + private readonly long _startTimestamp; + + public EmoteAnimation(IReadOnlyList frames) + { + _frames = frames.ToArray(); + _durationMs = Math.Max(1, frames.Sum(frame => frame.DurationMs)); + _startTimestamp = Stopwatch.GetTimestamp(); + } + + public IDalamudTextureWrap? GetCurrentFrame() + { + if (_frames.Length == 0) + { + return null; + } + + if (_frames.Length == 1) + { + return _frames[0].Texture; + } + + var elapsedTicks = Stopwatch.GetTimestamp() - _startTimestamp; + var elapsedMs = (elapsedTicks * 1000L) / Stopwatch.Frequency; + var targetMs = (int)(elapsedMs % _durationMs); + var accumulated = 0; + + foreach (var frame in _frames) + { + accumulated += frame.DurationMs; + if (targetMs < accumulated) + { + return frame.Texture; + } + } + + return _frames[^1].Texture; + } + + public IDalamudTextureWrap? GetStaticFrame() + { + if (_frames.Length == 0) + { + return null; + } + + return _frames[0].Texture; + } + + public void Dispose() + { + foreach (var frame in _frames) + { + frame.Texture.Dispose(); + } + } + } + + private readonly record struct EmoteFrame(IDalamudTextureWrap Texture, int DurationMs); + + private sealed class EmoteEntry : IDisposable + { + private int _loadingState; + private int _animationAttempted; + private IDalamudTextureWrap? _staticTexture; + private EmoteAnimation? _animation; + + public EmoteEntry(string code, EmoteSource source) + { + Code = code; + StaticUrl = source.StaticUrl; + AnimatedUrl = source.AnimatedUrl; + } + + public string Code { get; } + public string? StaticUrl { get; } + public string? AnimatedUrl { get; } + public bool HasStaticSource => !string.IsNullOrWhiteSpace(StaticUrl); + public bool HasAnimatedSource => !string.IsNullOrWhiteSpace(AnimatedUrl); + public bool HasStaticTexture => _staticTexture is not null; + public bool HasAttemptedAnimation => Interlocked.CompareExchange(ref _animationAttempted, 0, 0) != 0; + public bool NeedsAnimationLoad => _animation is null && HasAnimatedSource; + + public void MarkAnimationAttempted() + { + Interlocked.Exchange(ref _animationAttempted, 1); + } + + public bool TryGetTexture(bool allowAnimation, out IDalamudTextureWrap? texture) + { + if (allowAnimation && _animation is not null) + { + texture = _animation.GetCurrentFrame(); + return true; + } + + if (_staticTexture is not null) + { + texture = _staticTexture; + return true; + } + + if (!allowAnimation && _animation is not null) + { + texture = _animation.GetStaticFrame(); + return true; + } + + texture = null; + return false; + } + + public void EnsureLoading(bool allowAnimation, Action queueDownload, bool allowWhenStaticLoaded = false) + { + if (_animation is not null) + { + return; + } + + if (!allowWhenStaticLoaded && _staticTexture is not null) { return; } @@ -258,12 +737,22 @@ public sealed class ChatEmoteService : IDisposable return; } - queueDownload(this); + queueDownload(this, allowAnimation); } - public void SetTexture(IDalamudTextureWrap texture) + public void SetAnimation(EmoteAnimation animation) { - Texture = texture; + _staticTexture?.Dispose(); + _staticTexture = null; + _animation?.Dispose(); + _animation = animation; + Interlocked.Exchange(ref _loadingState, 0); + } + + public void SetStaticTexture(IDalamudTextureWrap texture) + { + _staticTexture?.Dispose(); + _staticTexture = texture; Interlocked.Exchange(ref _loadingState, 0); } @@ -271,5 +760,11 @@ public sealed class ChatEmoteService : IDisposable { Interlocked.Exchange(ref _loadingState, 0); } + + public void Dispose() + { + _animation?.Dispose(); + _staticTexture?.Dispose(); + } } } diff --git a/LightlessSync/Services/LightFinder/LightFinderScannerService.cs b/LightlessSync/Services/LightFinder/LightFinderScannerService.cs index 16de3c4..d83887a 100644 --- a/LightlessSync/Services/LightFinder/LightFinderScannerService.cs +++ b/LightlessSync/Services/LightFinder/LightFinderScannerService.cs @@ -83,12 +83,12 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase var now = DateTime.UtcNow; - foreach (var address in _actorTracker.PlayerAddresses) + foreach (var descriptor in _actorTracker.PlayerDescriptors) { - if (address == nint.Zero) + if (string.IsNullOrEmpty(descriptor.HashedContentId)) continue; - var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(address); + var cid = descriptor.HashedContentId; var isStale = !_broadcastCache.TryGetValue(cid, out var entry) || entry.ExpiryTime <= now; if (isStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < _maxQueueSize) diff --git a/LightlessSync/UI/ZoneChatUi.cs b/LightlessSync/UI/ZoneChatUi.cs index a03ceab..571b8ca 100644 --- a/LightlessSync/UI/ZoneChatUi.cs +++ b/LightlessSync/UI/ZoneChatUi.cs @@ -22,7 +22,6 @@ using LightlessSync.UI.Services; using LightlessSync.UI.Style; using LightlessSync.Utils; using Dalamud.Interface.Textures.TextureWraps; -using OtterGui.Text; using LightlessSync.WebAPI; using LightlessSync.WebAPI.SignalR.Utils; using Microsoft.Extensions.Logging; @@ -429,150 +428,182 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } else { - var itemHeight = ImGui.GetTextLineHeightWithSpacing(); - using var clipper = ImUtf8.ListClipper(channel.Messages.Count, itemHeight); - while (clipper.Step()) + var messageCount = channel.Messages.Count; + var contentMaxX = ImGui.GetWindowContentRegionMax().X; + var cursorStartX = ImGui.GetCursorPosX(); + var lineHeightWithSpacing = ImGui.GetTextLineHeightWithSpacing(); + var prefix = new float[messageCount + 1]; + var totalHeight = 0f; + + for (var i = 0; i < messageCount; i++) { - for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) + var messageHeight = MeasureMessageHeight(channel, channel.Messages[i], showTimestamps, cursorStartX, contentMaxX, itemSpacing, ref pairSnapshot); + if (messageHeight <= 0f) { - var message = channel.Messages[i]; - ImGui.PushID(i); + messageHeight = lineHeightWithSpacing; + } - if (message.IsSystem) + totalHeight += messageHeight; + prefix[i + 1] = totalHeight; + } + + var scrollY = ImGui.GetScrollY(); + var windowHeight = ImGui.GetWindowHeight(); + var startIndex = Math.Max(0, UpperBound(prefix, scrollY) - 1); + var endIndex = Math.Min(messageCount, LowerBound(prefix, scrollY + windowHeight)); + startIndex = Math.Max(0, startIndex - 2); + endIndex = Math.Min(messageCount, endIndex + 2); + + if (startIndex > 0) + { + ImGui.Dummy(new Vector2(1f, prefix[startIndex])); + } + + for (var i = startIndex; i < endIndex; i++) + { + var message = channel.Messages[i]; + ImGui.PushID(i); + + if (message.IsSystem) + { + DrawSystemEntry(message); + ImGui.PopID(); + continue; + } + + if (message.Payload is not { } payload) + { + ImGui.PopID(); + continue; + } + + var timestampText = string.Empty; + if (showTimestamps) + { + timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] "; + } + var color = message.FromSelf ? UIColors.Get("LightlessBlue") : ImGuiColors.DalamudWhite; + var showRoleIcons = false; + var isOwner = false; + var isModerator = false; + var isPinned = false; + + if (channel.Type == ChatChannelType.Group + && payload.Sender.Kind == ChatSenderKind.IdentifiedUser + && payload.Sender.User is not null) + { + pairSnapshot ??= _pairUiService.GetSnapshot(); + var groupId = channel.Descriptor.CustomKey; + if (!string.IsNullOrWhiteSpace(groupId) + && pairSnapshot.GroupsByGid.TryGetValue(groupId, out var groupInfo)) { - DrawSystemEntry(message); - ImGui.PopID(); - continue; + var senderUid = payload.Sender.User.UID; + isOwner = string.Equals(senderUid, groupInfo.OwnerUID, StringComparison.Ordinal); + if (groupInfo.GroupPairUserInfos.TryGetValue(senderUid, out var info)) + { + isModerator = info.IsModerator(); + isPinned = info.IsPinned(); + } } - if (message.Payload is not { } payload) + showRoleIcons = isOwner || isModerator || isPinned; + } + + ImGui.BeginGroup(); + ImGui.PushStyleColor(ImGuiCol.Text, color); + if (showRoleIcons) + { + if (!string.IsNullOrEmpty(timestampText)) { - ImGui.PopID(); - continue; + ImGui.TextUnformatted(timestampText); + ImGui.SameLine(0f, 0f); } - var timestampText = string.Empty; - if (showTimestamps) + var hasIcon = false; + if (isModerator) { - timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] "; - } - var color = message.FromSelf ? UIColors.Get("LightlessBlue") : ImGuiColors.DalamudWhite; - var showRoleIcons = false; - var isOwner = false; - var isModerator = false; - var isPinned = false; - - if (channel.Type == ChatChannelType.Group - && payload.Sender.Kind == ChatSenderKind.IdentifiedUser - && payload.Sender.User is not null) - { - pairSnapshot ??= _pairUiService.GetSnapshot(); - var groupId = channel.Descriptor.CustomKey; - if (!string.IsNullOrWhiteSpace(groupId) - && pairSnapshot.GroupsByGid.TryGetValue(groupId, out var groupInfo)) - { - var senderUid = payload.Sender.User.UID; - isOwner = string.Equals(senderUid, groupInfo.OwnerUID, StringComparison.Ordinal); - if (groupInfo.GroupPairUserInfos.TryGetValue(senderUid, out var info)) - { - isModerator = info.IsModerator(); - isPinned = info.IsPinned(); - } - } - - showRoleIcons = isOwner || isModerator || isPinned; + _uiSharedService.IconText(FontAwesomeIcon.UserShield, UIColors.Get("LightlessPurple")); + UiSharedService.AttachToolTip("Moderator"); + hasIcon = true; } - ImGui.BeginGroup(); - ImGui.PushStyleColor(ImGuiCol.Text, color); - if (showRoleIcons) + if (isOwner) { - if (!string.IsNullOrEmpty(timestampText)) - { - ImGui.TextUnformatted(timestampText); - ImGui.SameLine(0f, 0f); - } - - var hasIcon = false; - if (isModerator) - { - _uiSharedService.IconText(FontAwesomeIcon.UserShield, UIColors.Get("LightlessPurple")); - UiSharedService.AttachToolTip("Moderator"); - hasIcon = true; - } - - if (isOwner) - { - if (hasIcon) - { - ImGui.SameLine(0f, itemSpacing); - } - - _uiSharedService.IconText(FontAwesomeIcon.Crown, UIColors.Get("LightlessYellow")); - UiSharedService.AttachToolTip("Owner"); - hasIcon = true; - } - - if (isPinned) - { - if (hasIcon) - { - ImGui.SameLine(0f, itemSpacing); - } - - _uiSharedService.IconText(FontAwesomeIcon.Thumbtack, UIColors.Get("LightlessBlue")); - UiSharedService.AttachToolTip("Pinned"); - hasIcon = true; - } - if (hasIcon) { ImGui.SameLine(0f, itemSpacing); } - var messageStartX = ImGui.GetCursorPosX(); - DrawChatMessageWithEmotes($"{message.DisplayName}: ", payload.Message, messageStartX); + _uiSharedService.IconText(FontAwesomeIcon.Crown, UIColors.Get("LightlessYellow")); + UiSharedService.AttachToolTip("Owner"); + hasIcon = true; } - else - { - var messageStartX = ImGui.GetCursorPosX(); - DrawChatMessageWithEmotes($"{timestampText}{message.DisplayName}: ", payload.Message, messageStartX); - } - ImGui.PopStyleColor(); - ImGui.EndGroup(); - ImGui.SetNextWindowSizeConstraints( - new Vector2(190f * ImGuiHelpers.GlobalScale, 0f), - new Vector2(float.MaxValue, float.MaxValue)); - if (ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}")) + if (isPinned) { - var contextLocalTimestamp = payload.SentAtUtc.ToLocalTime(); - var contextTimestampText = contextLocalTimestamp.ToString("yyyy-MM-dd HH:mm:ss 'UTC'z", CultureInfo.InvariantCulture); - ImGui.TextDisabled(contextTimestampText); - if (channel.Type == ChatChannelType.Group - && payload.Sender.Kind == ChatSenderKind.IdentifiedUser - && payload.Sender.User is not null) + if (hasIcon) { - var aliasOrUid = payload.Sender.User.AliasOrUID; - if (!string.IsNullOrWhiteSpace(aliasOrUid) - && !string.Equals(message.DisplayName, aliasOrUid, StringComparison.Ordinal)) - { - ImGui.TextDisabled(aliasOrUid); - } - } - ImGui.Separator(); - - var actionIndex = 0; - foreach (var action in GetContextMenuActions(channel, message)) - { - DrawContextMenuAction(action, actionIndex++); + ImGui.SameLine(0f, itemSpacing); } - ImGui.EndPopup(); + _uiSharedService.IconText(FontAwesomeIcon.Thumbtack, UIColors.Get("LightlessBlue")); + UiSharedService.AttachToolTip("Pinned"); + hasIcon = true; } - ImGui.PopID(); + if (hasIcon) + { + ImGui.SameLine(0f, itemSpacing); + } + + var messageStartX = ImGui.GetCursorPosX(); + DrawChatMessageWithEmotes($"{message.DisplayName}: ", payload.Message, messageStartX); } + else + { + var messageStartX = ImGui.GetCursorPosX(); + DrawChatMessageWithEmotes($"{timestampText}{message.DisplayName}: ", payload.Message, messageStartX); + } + ImGui.PopStyleColor(); + ImGui.EndGroup(); + + ImGui.SetNextWindowSizeConstraints( + new Vector2(190f * ImGuiHelpers.GlobalScale, 0f), + new Vector2(float.MaxValue, float.MaxValue)); + if (ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}")) + { + var contextLocalTimestamp = payload.SentAtUtc.ToLocalTime(); + var contextTimestampText = contextLocalTimestamp.ToString("yyyy-MM-dd HH:mm:ss 'UTC'z", CultureInfo.InvariantCulture); + ImGui.TextDisabled(contextTimestampText); + if (channel.Type == ChatChannelType.Group + && payload.Sender.Kind == ChatSenderKind.IdentifiedUser + && payload.Sender.User is not null) + { + var aliasOrUid = payload.Sender.User.AliasOrUID; + if (!string.IsNullOrWhiteSpace(aliasOrUid) + && !string.Equals(message.DisplayName, aliasOrUid, StringComparison.Ordinal)) + { + ImGui.TextDisabled(aliasOrUid); + } + } + ImGui.Separator(); + + var actionIndex = 0; + foreach (var action in GetContextMenuActions(channel, message)) + { + DrawContextMenuAction(action, actionIndex++); + } + + ImGui.EndPopup(); + } + + ImGui.PopID(); + } + + var remainingHeight = totalHeight - prefix[endIndex]; + if (remainingHeight > 0f) + { + ImGui.Dummy(new Vector2(1f, remainingHeight)); } } @@ -708,7 +739,20 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase var clicked = false; if (texture is not null) { - clicked = ImGui.ImageButton(texture.Handle, new Vector2(emoteSize)); + var buttonSize = new Vector2(itemWidth, itemHeight); + clicked = ImGui.InvisibleButton("##emote_button", buttonSize); + var drawList = ImGui.GetWindowDrawList(); + var itemMin = ImGui.GetItemRectMin(); + var itemMax = ImGui.GetItemRectMax(); + var bgColor = ImGui.IsItemActive() + ? ImGui.GetColorU32(ImGuiCol.ButtonActive) + : ImGui.IsItemHovered() + ? ImGui.GetColorU32(ImGuiCol.ButtonHovered) + : ImGui.GetColorU32(ImGuiCol.Button); + drawList.AddRectFilled(itemMin, itemMax, bgColor, style.FrameRounding); + var imageMin = itemMin + style.FramePadding; + var imageMax = imageMin + new Vector2(emoteSize); + drawList.AddImage(texture.Handle, imageMin, imageMax); } else { @@ -878,7 +922,232 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private static bool IsEmoteChar(char value) { - return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '!'; + return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '!' || value == '(' || value == ')'; + } + + private float MeasureMessageHeight( + ChatChannelSnapshot channel, + ChatMessageEntry message, + bool showTimestamps, + float cursorStartX, + float contentMaxX, + float itemSpacing, + ref PairUiSnapshot? pairSnapshot) + { + if (message.IsSystem) + { + return MeasureSystemEntryHeight(message); + } + + if (message.Payload is not { } payload) + { + return 0f; + } + + var timestampText = string.Empty; + if (showTimestamps) + { + timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] "; + } + + var showRoleIcons = false; + var isOwner = false; + var isModerator = false; + var isPinned = false; + + if (channel.Type == ChatChannelType.Group + && payload.Sender.Kind == ChatSenderKind.IdentifiedUser + && payload.Sender.User is not null) + { + pairSnapshot ??= _pairUiService.GetSnapshot(); + var groupId = channel.Descriptor.CustomKey; + if (!string.IsNullOrWhiteSpace(groupId) + && pairSnapshot.GroupsByGid.TryGetValue(groupId, out var groupInfo)) + { + var senderUid = payload.Sender.User.UID; + isOwner = string.Equals(senderUid, groupInfo.OwnerUID, StringComparison.Ordinal); + if (groupInfo.GroupPairUserInfos.TryGetValue(senderUid, out var info)) + { + isModerator = info.IsModerator(); + isPinned = info.IsPinned(); + } + } + + showRoleIcons = isOwner || isModerator || isPinned; + } + + var lineStartX = cursorStartX; + string prefix; + if (showRoleIcons) + { + lineStartX += MeasureRolePrefixWidth(timestampText, isOwner, isModerator, isPinned, itemSpacing); + prefix = $"{message.DisplayName}: "; + } + else + { + prefix = $"{timestampText}{message.DisplayName}: "; + } + + var lines = MeasureChatMessageLines(prefix, payload.Message, lineStartX, contentMaxX); + return Math.Max(1, lines) * ImGui.GetTextLineHeightWithSpacing(); + } + + private int MeasureChatMessageLines(string prefix, string message, float lineStartX, float contentMaxX) + { + var segments = BuildChatSegments(prefix, message); + if (segments.Count == 0) + { + return 1; + } + + var emoteWidth = ImGui.GetTextLineHeight(); + var availableWidth = Math.Max(1f, contentMaxX - lineStartX); + var remainingWidth = availableWidth; + var firstOnLine = true; + var lines = 1; + + foreach (var segment in segments) + { + if (segment.IsLineBreak) + { + lines++; + firstOnLine = true; + remainingWidth = availableWidth; + continue; + } + + if (segment.IsWhitespace && firstOnLine) + { + continue; + } + + var segmentWidth = segment.IsEmote ? emoteWidth : ImGui.CalcTextSize(segment.Text).X; + if (!firstOnLine) + { + if (segmentWidth > remainingWidth) + { + lines++; + firstOnLine = true; + remainingWidth = availableWidth; + if (segment.IsWhitespace) + { + continue; + } + } + } + + remainingWidth -= segmentWidth; + firstOnLine = false; + } + + return lines; + } + + private float MeasureRolePrefixWidth(string timestampText, bool isOwner, bool isModerator, bool isPinned, float itemSpacing) + { + var width = 0f; + + if (!string.IsNullOrEmpty(timestampText)) + { + width += ImGui.CalcTextSize(timestampText).X; + } + + var hasIcon = false; + if (isModerator) + { + width += MeasureIconWidth(FontAwesomeIcon.UserShield); + hasIcon = true; + } + + if (isOwner) + { + if (hasIcon) + { + width += itemSpacing; + } + + width += MeasureIconWidth(FontAwesomeIcon.Crown); + hasIcon = true; + } + + if (isPinned) + { + if (hasIcon) + { + width += itemSpacing; + } + + width += MeasureIconWidth(FontAwesomeIcon.Thumbtack); + hasIcon = true; + } + + if (hasIcon) + { + width += itemSpacing; + } + + return width; + } + + private float MeasureIconWidth(FontAwesomeIcon icon) + { + using var font = _uiSharedService.IconFont.Push(); + return ImGui.CalcTextSize(icon.ToIconString()).X; + } + + private float MeasureSystemEntryHeight(ChatMessageEntry entry) + { + _ = entry; + var spacing = ImGui.GetStyle().ItemSpacing.Y; + var lineHeightWithSpacing = ImGui.GetTextLineHeightWithSpacing(); + var separatorHeight = Math.Max(1f, ImGuiHelpers.GlobalScale); + + var height = spacing; + height += lineHeightWithSpacing; + height += spacing * 0.35f; + height += separatorHeight; + height += spacing; + return height; + } + + private static int LowerBound(float[] values, float target) + { + var low = 0; + var high = values.Length; + while (low < high) + { + var mid = (low + high) / 2; + if (values[mid] < target) + { + low = mid + 1; + } + else + { + high = mid; + } + } + + return low; + } + + private static int UpperBound(float[] values, float target) + { + var low = 0; + var high = values.Length; + while (low < high) + { + var mid = (low + high) / 2; + if (values[mid] <= target) + { + low = mid + 1; + } + else + { + high = mid; + } + } + + return low; } private void DrawEmoteTooltip(string name, IDalamudTextureWrap? texture) @@ -2092,6 +2361,17 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.SetTooltip("When enabled, your notes replace user names in syncshell chat."); } + var enableAnimatedEmotes = chatConfig.EnableAnimatedEmotes; + if (ImGui.Checkbox("Enable animated emotes", ref enableAnimatedEmotes)) + { + chatConfig.EnableAnimatedEmotes = enableAnimatedEmotes; + _chatConfigService.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("When disabled, emotes render as static images."); + } + ImGui.Separator(); ImGui.TextUnformatted("Chat Visibility"); -- 2.49.1 From a2ed9f8d2b574cd1f0c3e30166571f0462a4bab0 Mon Sep 17 00:00:00 2001 From: defnotken Date: Mon, 5 Jan 2026 14:48:14 -0600 Subject: [PATCH 45/87] Adding memory violations catches and null checks to NameString and GameObj --- LightlessSync/Services/DalamudUtilService.cs | 38 ++++++++++++++------ 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index e399a9d..0806b42 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -955,7 +955,13 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber private unsafe void CheckCharacterForDrawing(nint address, string characterName) { + if (address == nint.Zero) + return; + var gameObj = (GameObject*)address; + + if (gameObj == null || gameObj->ObjectKind == 0) + return; var drawObj = gameObj->DrawObject; bool isDrawing = false; bool isDrawingChanged = false; @@ -1052,16 +1058,28 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber if (!IsAnythingDrawing) { - var gameObj = (GameObject*)playerAddress; - var currentName = gameObj != null ? gameObj->NameString ?? string.Empty : string.Empty; - var charaName = string.IsNullOrEmpty(currentName) ? actor.Name : currentName; - CheckCharacterForDrawing(playerAddress, charaName); - if (IsAnythingDrawing) - break; - } - else - { - break; + try + { + var gameObj = (GameObject*)playerAddress; + + if (gameObj == null || gameObj->ObjectKind == 0) + { + continue; + } + + var currentName = gameObj->NameString ?? string.Empty; + var charaName = string.IsNullOrEmpty(currentName) ? actor.Name : currentName; + + CheckCharacterForDrawing(playerAddress, charaName); + + if (IsAnythingDrawing) + break; + } + catch (AccessViolationException ex) + { + _logger.LogWarning(ex, "Memory access violation reading character at {addr}", playerAddress.ToString("X")); + continue; + } } } }); -- 2.49.1 From 9048b3bd87bb384f927646a6c770376484ac7b51 Mon Sep 17 00:00:00 2001 From: defnotken Date: Mon, 5 Jan 2026 15:07:48 -0600 Subject: [PATCH 46/87] more checks on drawing --- LightlessSync/Services/DalamudUtilService.cs | 83 ++++++++++++-------- 1 file changed, 51 insertions(+), 32 deletions(-) diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 0806b42..2606d7b 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -958,59 +958,78 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber if (address == nint.Zero) return; - var gameObj = (GameObject*)address; - - if (gameObj == null || gameObj->ObjectKind == 0) - return; - var drawObj = gameObj->DrawObject; - bool isDrawing = false; - bool isDrawingChanged = false; - if ((nint)drawObj != IntPtr.Zero) + try { - isDrawing = gameObj->RenderFlags == (VisibilityFlags)0b100000000000; - if (!isDrawing) + var gameObj = (GameObject*)address; + + if (gameObj == null || gameObj->ObjectKind == 0) + return; + + if (!IsGameObjectPresent(address)) { - isDrawing = ((CharacterBase*)drawObj)->HasModelInSlotLoaded != 0; + _logger.LogDebug("Character {name} at {addr} no longer present in object table", characterName, address.ToString("X")); + return; + } + + var drawObj = gameObj->DrawObject; + bool isDrawing = false; + bool isDrawingChanged = false; + + if ((nint)drawObj != IntPtr.Zero) + { + isDrawing = gameObj->RenderFlags == (VisibilityFlags)0b100000000000; if (!isDrawing) { - isDrawing = ((CharacterBase*)drawObj)->HasModelFilesInSlotLoaded != 0; - if (isDrawing && !string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal) - && !string.Equals(_lastGlobalBlockReason, "HasModelFilesInSlotLoaded", StringComparison.Ordinal)) + var charBase = (CharacterBase*)drawObj; + if (charBase != null) { - _lastGlobalBlockPlayer = characterName; - _lastGlobalBlockReason = "HasModelFilesInSlotLoaded"; - isDrawingChanged = true; + isDrawing = charBase->HasModelInSlotLoaded != 0; + if (!isDrawing) + { + isDrawing = charBase->HasModelFilesInSlotLoaded != 0; + if (isDrawing && !string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal) + && !string.Equals(_lastGlobalBlockReason, "HasModelFilesInSlotLoaded", StringComparison.Ordinal)) + { + _lastGlobalBlockPlayer = characterName; + _lastGlobalBlockReason = "HasModelFilesInSlotLoaded"; + isDrawingChanged = true; + } + } + else + { + if (!string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal) + && !string.Equals(_lastGlobalBlockReason, "HasModelInSlotLoaded", StringComparison.Ordinal)) + { + _lastGlobalBlockPlayer = characterName; + _lastGlobalBlockReason = "HasModelInSlotLoaded"; + isDrawingChanged = true; + } + } } } else { if (!string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal) - && !string.Equals(_lastGlobalBlockReason, "HasModelInSlotLoaded", StringComparison.Ordinal)) + && !string.Equals(_lastGlobalBlockReason, "RenderFlags", StringComparison.Ordinal)) { _lastGlobalBlockPlayer = characterName; - _lastGlobalBlockReason = "HasModelInSlotLoaded"; + _lastGlobalBlockReason = "RenderFlags"; isDrawingChanged = true; } } } - else + + if (isDrawingChanged) { - if (!string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal) - && !string.Equals(_lastGlobalBlockReason, "RenderFlags", StringComparison.Ordinal)) - { - _lastGlobalBlockPlayer = characterName; - _lastGlobalBlockReason = "RenderFlags"; - isDrawingChanged = true; - } + _logger.LogTrace("Global draw block: START => {name} ({reason})", characterName, _lastGlobalBlockReason); } - } - if (isDrawingChanged) + IsAnythingDrawing |= isDrawing; + } + catch (AccessViolationException ex) { - _logger.LogTrace("Global draw block: START => {name} ({reason})", characterName, _lastGlobalBlockReason); + _logger.LogWarning(ex, "access violation checking character {name} at {addr}", characterName, address.ToString("X")); } - - IsAnythingDrawing |= isDrawing; } private void FrameworkOnUpdate(IFramework framework) -- 2.49.1 From d00df84ed608ca4a25ab5391f5a027625d3f3ac6 Mon Sep 17 00:00:00 2001 From: defnotken Date: Mon, 5 Jan 2026 15:39:18 -0600 Subject: [PATCH 47/87] even more violation checks.... --- LightlessSync/Services/DalamudUtilService.cs | 172 ++++++++++++------- 1 file changed, 111 insertions(+), 61 deletions(-) diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 2606d7b..684c011 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -962,50 +962,68 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber { var gameObj = (GameObject*)address; - if (gameObj == null || gameObj->ObjectKind == 0) + if (gameObj == null) return; - if (!IsGameObjectPresent(address)) + if (!_objectTable.Any(o => o?.Address == address)) { - _logger.LogDebug("Character {name} at {addr} no longer present in object table", characterName, address.ToString("X")); + _logger.LogDebug("Character {name} at {addr} no longer in object table", characterName, address.ToString("X")); return; } + if (gameObj->ObjectKind == 0) + return; + var drawObj = gameObj->DrawObject; bool isDrawing = false; bool isDrawingChanged = false; if ((nint)drawObj != IntPtr.Zero) { - isDrawing = gameObj->RenderFlags == (VisibilityFlags)0b100000000000; + try + { + isDrawing = gameObj->RenderFlags == (VisibilityFlags)0b100000000000; + } + catch (AccessViolationException) + { + return; + } + if (!isDrawing) { - var charBase = (CharacterBase*)drawObj; - if (charBase != null) + try { - isDrawing = charBase->HasModelInSlotLoaded != 0; - if (!isDrawing) + var charBase = (CharacterBase*)drawObj; + if (charBase != null) { - isDrawing = charBase->HasModelFilesInSlotLoaded != 0; - if (isDrawing && !string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal) - && !string.Equals(_lastGlobalBlockReason, "HasModelFilesInSlotLoaded", StringComparison.Ordinal)) + isDrawing = charBase->HasModelInSlotLoaded != 0; + if (!isDrawing) { - _lastGlobalBlockPlayer = characterName; - _lastGlobalBlockReason = "HasModelFilesInSlotLoaded"; - isDrawingChanged = true; - } - } - else - { - if (!string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal) - && !string.Equals(_lastGlobalBlockReason, "HasModelInSlotLoaded", StringComparison.Ordinal)) - { - _lastGlobalBlockPlayer = characterName; - _lastGlobalBlockReason = "HasModelInSlotLoaded"; - isDrawingChanged = true; + isDrawing = charBase->HasModelFilesInSlotLoaded != 0; + if (isDrawing && !string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal) + && !string.Equals(_lastGlobalBlockReason, "HasModelFilesInSlotLoaded", StringComparison.Ordinal)) + { + _lastGlobalBlockPlayer = characterName; + _lastGlobalBlockReason = "HasModelFilesInSlotLoaded"; + isDrawingChanged = true; + } + } + else + { + if (!string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal) + && !string.Equals(_lastGlobalBlockReason, "HasModelInSlotLoaded", StringComparison.Ordinal)) + { + _lastGlobalBlockPlayer = characterName; + _lastGlobalBlockReason = "HasModelInSlotLoaded"; + isDrawingChanged = true; + } } } } + catch (AccessViolationException) + { + return; + } } else { @@ -1028,7 +1046,11 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber } catch (AccessViolationException ex) { - _logger.LogWarning(ex, "access violation checking character {name} at {addr}", characterName, address.ToString("X")); + _logger.LogDebug(ex, "Memory access violation checking character {name} at {addr}", characterName, address.ToString("X")); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Unexpected error checking character {name} at {addr}", characterName, address.ToString("X")); } } @@ -1057,51 +1079,79 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber _actorObjectService.RefreshTrackedActors(); } + // CRITICAL: Capture snapshot once to prevent mid-iteration changes var playerDescriptors = _actorObjectService.PlayerDescriptors; - for (var i = 0; i < playerDescriptors.Count; i++) + var descriptorCount = playerDescriptors.Count; + + for (var i = 0; i < descriptorCount; i++) { - var actor = playerDescriptors[i]; - - var playerAddress = actor.Address; - if (playerAddress == nint.Zero) - continue; - - if (actor.ObjectIndex >= 200) - continue; - - if (_blockedCharacterHandler.IsCharacterBlocked(playerAddress, actor.ObjectIndex, out bool firstTime) && firstTime) + try { - _logger.LogTrace("Skipping character {addr}, blocked/muted", playerAddress.ToString("X")); - continue; - } + // Revalidate the count in case collection changed + if (i >= playerDescriptors.Count) + break; - if (!IsAnythingDrawing) - { - try + var actor = playerDescriptors[i]; + + var playerAddress = actor.Address; + if (playerAddress == nint.Zero) + continue; + + if (actor.ObjectIndex >= 200) + continue; + + if (_blockedCharacterHandler.IsCharacterBlocked(playerAddress, actor.ObjectIndex, out bool firstTime) && firstTime) { - var gameObj = (GameObject*)playerAddress; - - if (gameObj == null || gameObj->ObjectKind == 0) - { - continue; - } - - var currentName = gameObj->NameString ?? string.Empty; - var charaName = string.IsNullOrEmpty(currentName) ? actor.Name : currentName; - - CheckCharacterForDrawing(playerAddress, charaName); - - if (IsAnythingDrawing) - break; - } - catch (AccessViolationException ex) - { - _logger.LogWarning(ex, "Memory access violation reading character at {addr}", playerAddress.ToString("X")); + _logger.LogTrace("Skipping character {addr}, blocked/muted", playerAddress.ToString("X")); continue; } + + if (!IsAnythingDrawing) + { + // Wrap ALL pointer access in try-catch + try + { + var gameObj = (GameObject*)playerAddress; + + if (gameObj == null || gameObj->ObjectKind == 0) + { + continue; + } + + // Get name with protection - NameString internally dereferences pointers + string currentName; + try + { + currentName = gameObj->NameString ?? string.Empty; + } + catch (AccessViolationException) + { + currentName = string.Empty; + } + + var charaName = string.IsNullOrEmpty(currentName) ? actor.Name : currentName; + + CheckCharacterForDrawing(playerAddress, charaName); + + if (IsAnythingDrawing) + break; + } + catch (AccessViolationException ex) + { + _logger.LogDebug(ex, "Access violation on GameObject pointer for actor {index} at {addr}", i, playerAddress.ToString("X")); + } + } + } + catch (AccessViolationException ex) + { + _logger.LogDebug(ex, "Access violation processing actor {index} - object likely destroyed", i); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Unexpected error processing actor {index}", i); } } - }); + }); if (!IsAnythingDrawing && !string.IsNullOrEmpty(_lastGlobalBlockPlayer)) { -- 2.49.1 From 4eec363cd286787fb5964764a6e41c8c8739abda Mon Sep 17 00:00:00 2001 From: defnotken Date: Mon, 5 Jan 2026 15:40:32 -0600 Subject: [PATCH 48/87] yeet some comments --- LightlessSync/Services/DalamudUtilService.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 684c011..9ae2a39 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -1079,7 +1079,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber _actorObjectService.RefreshTrackedActors(); } - // CRITICAL: Capture snapshot once to prevent mid-iteration changes var playerDescriptors = _actorObjectService.PlayerDescriptors; var descriptorCount = playerDescriptors.Count; @@ -1087,7 +1086,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber { try { - // Revalidate the count in case collection changed if (i >= playerDescriptors.Count) break; @@ -1108,7 +1106,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber if (!IsAnythingDrawing) { - // Wrap ALL pointer access in try-catch try { var gameObj = (GameObject*)playerAddress; @@ -1118,7 +1115,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber continue; } - // Get name with protection - NameString internally dereferences pointers string currentName; try { -- 2.49.1 From f307c65c6652f81d74a96c8ddc8d950189a0202f Mon Sep 17 00:00:00 2001 From: defnotken Date: Mon, 5 Jan 2026 17:19:31 -0600 Subject: [PATCH 49/87] check nulls remove redundant catches. --- .../PlayerData/Factories/PlayerDataFactory.cs | 42 ++- LightlessSync/Services/DalamudUtilService.cs | 270 ++++++++---------- 2 files changed, 153 insertions(+), 159 deletions(-) diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index 7b76953..a11ae91 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -11,6 +11,8 @@ using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Diagnostics; +using System.Runtime.ExceptionServices; +using System.Runtime.InteropServices; namespace LightlessSync.PlayerData.Factories; @@ -123,22 +125,38 @@ public class PlayerDataFactory { if (playerPointer == IntPtr.Zero) return true; + + if (!IsPointerValid(playerPointer)) + return true; + + var character = (Character*)playerPointer; + if (character == null) + return true; + + var gameObject = &character->GameObject; + if (gameObject == null) + return true; + + if (!IsPointerValid((IntPtr)gameObject)) + return true; + + return gameObject->DrawObject == null; + } + + private static bool IsPointerValid(IntPtr ptr) + { + if (ptr == IntPtr.Zero) + return false; + try { - var character = (Character*)playerPointer; - if (character == null) - return true; - - var gameObject = &character->GameObject; - if (gameObject == null) - return true; - - return gameObject->DrawObject == null; - } - catch (AccessViolationException) - { + _ = Marshal.ReadByte(ptr); return true; } + catch + { + return false; + } } private static bool IsCacheFresh(CacheEntry entry) diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 9ae2a39..5da96bb 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -666,7 +666,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber var location = new LocationInfo(); location.ServerId = _playerState.CurrentWorld.RowId; - location.InstanceId = UIState.Instance()->PublicInstance.InstanceId; + location.InstanceId = UIState.Instance()->PublicInstance.InstanceId; location.TerritoryId = _clientState.TerritoryType; location.MapId = _clientState.MapId; if (houseMan != null) @@ -699,13 +699,13 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber } return location; } - + public string LocationToString(LocationInfo location) { if (location.ServerId is 0 || location.TerritoryId is 0) return String.Empty; var str = WorldData.Value[(ushort)location.ServerId]; - if (ContentFinderData.Value.TryGetValue(location.TerritoryId , out var dutyName)) + if (ContentFinderData.Value.TryGetValue(location.TerritoryId, out var dutyName)) { str += $" - [In Duty]{dutyName}"; } @@ -856,7 +856,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber var token = ct ?? CancellationToken.None; - const int tick = 250; + const int tick = 250; const int initialSettle = 50; var sw = Stopwatch.StartNew(); @@ -881,7 +881,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber { // ignore } - catch (AccessViolationException ex) + catch (Exception ex) { logger.LogWarning(ex, "Error accessing {handler}, object does not exist anymore?", handler); } @@ -922,11 +922,11 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber public string? GetWorldNameFromPlayerAddress(nint address) { if (address == nint.Zero) return null; - + EnsureIsOnFramework(); var playerCharacter = _objectTable.OfType().FirstOrDefault(p => p.Address == address); if (playerCharacter == null) return null; - + var worldId = (ushort)playerCharacter.HomeWorld.RowId; return WorldData.Value.TryGetValue(worldId, out var worldName) ? worldName : null; } @@ -953,105 +953,108 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber }); } + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool IsBadReadPtr(IntPtr ptr, UIntPtr size); + + private static bool IsValidPointer(nint ptr, int size = 8) + { + if (ptr == nint.Zero) + return false; + + try + { + if (!Util.IsWine()) + { + return !IsBadReadPtr(ptr, (UIntPtr)size); + } + return ptr != nint.Zero && (ptr % IntPtr.Size) == 0; + } + catch + { + return false; + } + } + private unsafe void CheckCharacterForDrawing(nint address, string characterName) { if (address == nint.Zero) return; - try + if (!IsValidPointer(address)) { - var gameObj = (GameObject*)address; + _logger.LogDebug("Invalid pointer for character {name} at {addr}", characterName, address.ToString("X")); + return; + } - if (gameObj == null) - return; + var gameObj = (GameObject*)address; - if (!_objectTable.Any(o => o?.Address == address)) + if (gameObj == null) + return; + + if (!_objectTable.Any(o => o?.Address == address)) + { + _logger.LogDebug("Character {name} at {addr} no longer in object table", characterName, address.ToString("X")); + return; + } + + if (gameObj->ObjectKind == 0) + return; + + var drawObj = gameObj->DrawObject; + bool isDrawing = false; + bool isDrawingChanged = false; + + if ((nint)drawObj != IntPtr.Zero && IsValidPointer((nint)drawObj)) + { + isDrawing = gameObj->RenderFlags == (VisibilityFlags)0b100000000000; + + if (!isDrawing) { - _logger.LogDebug("Character {name} at {addr} no longer in object table", characterName, address.ToString("X")); - return; - } - - if (gameObj->ObjectKind == 0) - return; - - var drawObj = gameObj->DrawObject; - bool isDrawing = false; - bool isDrawingChanged = false; - - if ((nint)drawObj != IntPtr.Zero) - { - try + var charBase = (CharacterBase*)drawObj; + if (charBase != null && IsValidPointer((nint)charBase)) { - isDrawing = gameObj->RenderFlags == (VisibilityFlags)0b100000000000; - } - catch (AccessViolationException) - { - return; - } - - if (!isDrawing) - { - try + isDrawing = charBase->HasModelInSlotLoaded != 0; + if (!isDrawing) { - var charBase = (CharacterBase*)drawObj; - if (charBase != null) + isDrawing = charBase->HasModelFilesInSlotLoaded != 0; + if (isDrawing && !string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal) + && !string.Equals(_lastGlobalBlockReason, "HasModelFilesInSlotLoaded", StringComparison.Ordinal)) { - isDrawing = charBase->HasModelInSlotLoaded != 0; - if (!isDrawing) - { - isDrawing = charBase->HasModelFilesInSlotLoaded != 0; - if (isDrawing && !string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal) - && !string.Equals(_lastGlobalBlockReason, "HasModelFilesInSlotLoaded", StringComparison.Ordinal)) - { - _lastGlobalBlockPlayer = characterName; - _lastGlobalBlockReason = "HasModelFilesInSlotLoaded"; - isDrawingChanged = true; - } - } - else - { - if (!string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal) - && !string.Equals(_lastGlobalBlockReason, "HasModelInSlotLoaded", StringComparison.Ordinal)) - { - _lastGlobalBlockPlayer = characterName; - _lastGlobalBlockReason = "HasModelInSlotLoaded"; - isDrawingChanged = true; - } - } + _lastGlobalBlockPlayer = characterName; + _lastGlobalBlockReason = "HasModelFilesInSlotLoaded"; + isDrawingChanged = true; } } - catch (AccessViolationException) + else { - return; - } - } - else - { - if (!string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal) - && !string.Equals(_lastGlobalBlockReason, "RenderFlags", StringComparison.Ordinal)) - { - _lastGlobalBlockPlayer = characterName; - _lastGlobalBlockReason = "RenderFlags"; - isDrawingChanged = true; + if (!string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal) + && !string.Equals(_lastGlobalBlockReason, "HasModelInSlotLoaded", StringComparison.Ordinal)) + { + _lastGlobalBlockPlayer = characterName; + _lastGlobalBlockReason = "HasModelInSlotLoaded"; + isDrawingChanged = true; + } } } } - - if (isDrawingChanged) + else { - _logger.LogTrace("Global draw block: START => {name} ({reason})", characterName, _lastGlobalBlockReason); + if (!string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal) + && !string.Equals(_lastGlobalBlockReason, "RenderFlags", StringComparison.Ordinal)) + { + _lastGlobalBlockPlayer = characterName; + _lastGlobalBlockReason = "RenderFlags"; + isDrawingChanged = true; + } } + } - IsAnythingDrawing |= isDrawing; - } - catch (AccessViolationException ex) + if (isDrawingChanged) { - _logger.LogDebug(ex, "Memory access violation checking character {name} at {addr}", characterName, address.ToString("X")); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Unexpected error checking character {name} at {addr}", characterName, address.ToString("X")); + _logger.LogTrace("Global draw block: START => {name} ({reason})", characterName, _lastGlobalBlockReason); } + + IsAnythingDrawing |= isDrawing; } private void FrameworkOnUpdate(IFramework framework) @@ -1061,6 +1064,11 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber private unsafe void FrameworkOnUpdateInternal() { + if (!_clientState.IsLoggedIn || _objectTable.LocalPlayer == null) + { + return; + } + if ((_objectTable.LocalPlayer?.IsDead ?? false) && _condition[ConditionFlag.BoundByDuty]) { return; @@ -1084,70 +1092,38 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber for (var i = 0; i < descriptorCount; i++) { - try + if (i >= playerDescriptors.Count) + break; + + var actor = playerDescriptors[i]; + + var playerAddress = actor.Address; + if (playerAddress == nint.Zero || !IsValidPointer(playerAddress)) + continue; + + if (actor.ObjectIndex >= 200) + continue; + + if (_blockedCharacterHandler.IsCharacterBlocked(playerAddress, actor.ObjectIndex, out bool firstTime) && firstTime) { - if (i >= playerDescriptors.Count) + _logger.LogTrace("Skipping character {addr}, blocked/muted", playerAddress.ToString("X")); + continue; + } + + if (!IsAnythingDrawing) + { + if (!_objectTable.Any(o => o?.Address == playerAddress)) + { + continue; + } + + CheckCharacterForDrawing(playerAddress, actor.Name); + + if (IsAnythingDrawing) break; - - var actor = playerDescriptors[i]; - - var playerAddress = actor.Address; - if (playerAddress == nint.Zero) - continue; - - if (actor.ObjectIndex >= 200) - continue; - - if (_blockedCharacterHandler.IsCharacterBlocked(playerAddress, actor.ObjectIndex, out bool firstTime) && firstTime) - { - _logger.LogTrace("Skipping character {addr}, blocked/muted", playerAddress.ToString("X")); - continue; - } - - if (!IsAnythingDrawing) - { - try - { - var gameObj = (GameObject*)playerAddress; - - if (gameObj == null || gameObj->ObjectKind == 0) - { - continue; - } - - string currentName; - try - { - currentName = gameObj->NameString ?? string.Empty; - } - catch (AccessViolationException) - { - currentName = string.Empty; - } - - var charaName = string.IsNullOrEmpty(currentName) ? actor.Name : currentName; - - CheckCharacterForDrawing(playerAddress, charaName); - - if (IsAnythingDrawing) - break; - } - catch (AccessViolationException ex) - { - _logger.LogDebug(ex, "Access violation on GameObject pointer for actor {index} at {addr}", i, playerAddress.ToString("X")); - } - } - } - catch (AccessViolationException ex) - { - _logger.LogDebug(ex, "Access violation processing actor {index} - object likely destroyed", i); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Unexpected error processing actor {index}", i); } } - }); + }); if (!IsAnythingDrawing && !string.IsNullOrEmpty(_lastGlobalBlockPlayer)) { @@ -1214,7 +1190,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber }); // Cutscene - HandleStateTransition(() => IsInCutscene,v => IsInCutscene = v, shouldBeInCutscene, "Cutscene", + HandleStateTransition(() => IsInCutscene, v => IsInCutscene = v, shouldBeInCutscene, "Cutscene", onEnter: () => { Mediator.Publish(new CutsceneStartMessage()); @@ -1257,7 +1233,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber Mediator.Publish(new ZoneSwitchEndMessage()); Mediator.Publish(new ResumeScanMessage(nameof(ConditionFlag.BetweenAreas))); } - + //Map if (!_sentBetweenAreas) { @@ -1268,7 +1244,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber Mediator.Publish(new MapChangedMessage(mapid)); } } - + var localPlayer = _objectTable.LocalPlayer; if (localPlayer != null) @@ -1354,4 +1330,4 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber onExit(); } } -} +} \ No newline at end of file -- 2.49.1 From 4bb8db8c0374b93943bc04b5b872982efa48494f Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 6 Jan 2026 00:22:22 +0100 Subject: [PATCH 50/87] Game object handler changes. --- .../PlayerData/Handlers/GameObjectHandler.cs | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs index f3d1a4b..efff84d 100644 --- a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs +++ b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs @@ -78,7 +78,6 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP if (msg.Address == Address) { _haltProcessing = false; - Refresh(); } }); @@ -114,16 +113,16 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP public async Task ActOnFrameworkAfterEnsureNoDrawAsync(Action act, CancellationToken token) { while (await _dalamudUtil.RunOnFrameworkThread(() => - { - EnsureLatestObjectState(); - if (CurrentDrawCondition != DrawCondition.None) return true; - var gameObj = _dalamudUtil.CreateGameObject(Address); - if (gameObj is Dalamud.Game.ClientState.Objects.Types.ICharacter chara) - { - act.Invoke(chara); - } - return false; - }).ConfigureAwait(false)) + { + EnsureLatestObjectState(); + if (CurrentDrawCondition != DrawCondition.None) return true; + var gameObj = _dalamudUtil.CreateGameObject(Address); + if (gameObj is Dalamud.Game.ClientState.Objects.Types.ICharacter chara) + { + act.Invoke(chara); + } + return false; + }).ConfigureAwait(false)) { await Task.Delay(250, token).ConfigureAwait(false); } @@ -183,6 +182,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP { var prevAddr = Address; var prevDrawObj = DrawObjectAddress; + string? nameString = null; Address = _getAddress(); @@ -193,10 +193,9 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP EntityId = gameObject->EntityId; var chara = (Character*)Address; - var newName = chara->GameObject.NameString; - - if (!string.IsNullOrEmpty(newName) && !string.Equals(newName, Name, StringComparison.Ordinal)) - Name = newName; + nameString = chara->GameObject.NameString; + if (!string.IsNullOrEmpty(nameString) && !string.Equals(nameString, Name, StringComparison.Ordinal)) + Name = nameString; } else { @@ -214,16 +213,18 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP if (Address != IntPtr.Zero && DrawObjectAddress != IntPtr.Zero) { var chara = (Character*)Address; - var name = chara->GameObject.NameString; - bool nameChange = !string.Equals(name, Name, StringComparison.Ordinal); - if (nameChange) - { - Name = name; - } + var drawObj = (DrawObject*)DrawObjectAddress; + var objType = drawObj->Object.GetObjectType(); + var isHuman = objType == ObjectType.CharacterBase + && ((CharacterBase*)drawObj)->GetModelType() == CharacterBase.ModelType.Human; + + nameString ??= ((Character*)Address)->GameObject.NameString; + var nameChange = !string.Equals(nameString, Name, StringComparison.Ordinal); + if (nameChange) Name = nameString; + bool equipDiff = false; - if (((DrawObject*)DrawObjectAddress)->Object.GetObjectType() == ObjectType.CharacterBase - && ((CharacterBase*)DrawObjectAddress)->GetModelType() == CharacterBase.ModelType.Human) + if (isHuman) { var classJob = chara->CharacterData.ClassJob; if (classJob != _classJob) @@ -233,7 +234,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP Mediator.Publish(new ClassJobChangedMessage(this)); } - equipDiff = CompareAndUpdateEquipByteData((byte*)&((Human*)DrawObjectAddress)->Head); + equipDiff = CompareAndUpdateEquipByteData((byte*)&((Human*)drawObj)->Head); ref var mh = ref chara->DrawData.Weapon(WeaponSlot.MainHand); ref var oh = ref chara->DrawData.Weapon(WeaponSlot.OffHand); @@ -258,12 +259,11 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP bool customizeDiff = false; - if (((DrawObject*)DrawObjectAddress)->Object.GetObjectType() == ObjectType.CharacterBase - && ((CharacterBase*)DrawObjectAddress)->GetModelType() == CharacterBase.ModelType.Human) + if (isHuman) { - var gender = ((Human*)DrawObjectAddress)->Customize.Sex; - var raceId = ((Human*)DrawObjectAddress)->Customize.Race; - var tribeId = ((Human*)DrawObjectAddress)->Customize.Tribe; + var gender = ((Human*)drawObj)->Customize.Sex; + var raceId = ((Human*)drawObj)->Customize.Race; + var tribeId = ((Human*)drawObj)->Customize.Tribe; if (_isOwnedObject && ObjectKind == ObjectKind.Player && (gender != Gender || raceId != RaceId || tribeId != TribeId)) @@ -274,7 +274,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP TribeId = tribeId; } - customizeDiff = CompareAndUpdateCustomizeData(((Human*)DrawObjectAddress)->Customize.Data); + customizeDiff = CompareAndUpdateCustomizeData(((Human*)drawObj)->Customize.Data); if (customizeDiff) Logger.LogTrace("Checking [{this}] customize data as human from draw obj, result: {diff}", this, customizeDiff); } -- 2.49.1 From 775b128cf30565de95cf17d6bdb80ca11c649146 Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 6 Jan 2026 00:23:24 +0100 Subject: [PATCH 51/87] Removal of parameter --- LightlessSync/PlayerData/Handlers/GameObjectHandler.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs index efff84d..ac5c515 100644 --- a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs +++ b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs @@ -176,9 +176,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP Mediator.Publish(new GameObjectHandlerDestroyedMessage(this, _isOwnedObject)); } - private void CheckAndUpdateObject() => CheckAndUpdateObject(allowPublish: true); - - private unsafe void CheckAndUpdateObject(bool allowPublish = true) + private unsafe void CheckAndUpdateObject() { var prevAddr = Address; var prevDrawObj = DrawObjectAddress; -- 2.49.1 From 032201ed9e2c18d4a351250c46ca0a368e7bb82b Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 6 Jan 2026 00:31:08 +0100 Subject: [PATCH 52/87] Changed logging, last change of gameobject --- .../PlayerData/Factories/PlayerDataFactory.cs | 12 +++++++++--- .../PlayerData/Handlers/GameObjectHandler.cs | 9 ++------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index a11ae91..5e1d99e 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -1,4 +1,5 @@ -using FFXIVClientStructs.FFXIV.Client.Game.Character; +using Dalamud.Utility; +using FFXIVClientStructs.FFXIV.Client.Game.Character; using LightlessSync.API.Data.Enum; using LightlessSync.FileCache; using LightlessSync.Interop.Ipc; @@ -555,6 +556,11 @@ public class PlayerDataFactory 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); @@ -606,8 +612,8 @@ public class PlayerDataFactory noValidationFailed++; _logger.LogWarning( - "Animation PAP hash {hash} is not compatible with local skeletons; dropping all mappings for this hash. Reason: {reason}", - hash, + "Animation PAP is not compatible with local skeletons; dropping mappings for {papPath}. Reason: {reason}", + papPathSummary, reason); var removedGamePaths = fragment.FileReplacements diff --git a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs index ac5c515..28b67b6 100644 --- a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs +++ b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs @@ -169,14 +169,9 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP return $"{owned}/{ObjectKind}:{Name} ({Address:X},{DrawObjectAddress:X})"; } - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); + private void CheckAndUpdateObject() => CheckAndUpdateObject(allowPublish: true); - Mediator.Publish(new GameObjectHandlerDestroyedMessage(this, _isOwnedObject)); - } - - private unsafe void CheckAndUpdateObject() + private unsafe void CheckAndUpdateObject(bool allowPublish) { var prevAddr = Address; var prevDrawObj = DrawObjectAddress; -- 2.49.1 From 9b9010ab8e4c857be574b76a91ee7dcef9e27dc8 Mon Sep 17 00:00:00 2001 From: defnotken Date: Mon, 5 Jan 2026 18:57:18 -0600 Subject: [PATCH 53/87] Defenses? --- .../PlayerData/Factories/PlayerDataFactory.cs | 95 +++-- LightlessSync/Services/XivDataAnalyzer.cs | 366 ++++++++++++------ 2 files changed, 324 insertions(+), 137 deletions(-) diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index 5e1d99e..9141a9b 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -566,8 +566,21 @@ public class PlayerDataFactory await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false); try { - papIndices = await Task.Run(() => _modelAnalyzer.GetBoneIndicesFromPap(hash), ct) - .ConfigureAwait(false); + try + { + papIndices = await Task.Run(() => _modelAnalyzer.GetBoneIndicesFromPap(hash, persistToConfig: false), ct) + .ConfigureAwait(false); + } + catch (SEHException ex) + { + _logger.LogError(ex, "SEH exception while parsing PAP file (hash={hash}, path={path}). Error code: 0x{code:X}. Skipping this animation.", hash, papPathSummary, ex.ErrorCode); + continue; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error parsing PAP file (hash={hash}, path={path}). Skipping this animation.", hash, papPathSummary); + continue; + } } finally { @@ -577,36 +590,68 @@ public class PlayerDataFactory if (papIndices == null || papIndices.Count == 0) continue; - if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105)) + bool hasValidIndices = false; + try + { + hasValidIndices = papIndices.All(k => k.Value != null && k.Value.DefaultIfEmpty().Max() <= 105); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error validating bone indices for PAP (hash={hash}, path={path}). Skipping.", hash, papPathSummary); + continue; + } + + if (hasValidIndices) continue; if (_logger.IsEnabled(LogLevel.Debug)) { - var papBuckets = papIndices - .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(); + 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)); + _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); + } } - if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out var reason)) + 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++; diff --git a/LightlessSync/Services/XivDataAnalyzer.cs b/LightlessSync/Services/XivDataAnalyzer.cs index 997df16..cd3b20c 100644 --- a/LightlessSync/Services/XivDataAnalyzer.cs +++ b/LightlessSync/Services/XivDataAnalyzer.cs @@ -1,5 +1,6 @@ using FFXIVClientStructs.FFXIV.Client.Game.Character; using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using FFXIVClientStructs.Havok.Common.Serialize.Resource; using FFXIVClientStructs.Havok.Animation; using FFXIVClientStructs.Havok.Common.Base.Types; using FFXIVClientStructs.Havok.Common.Serialize.Util; @@ -145,156 +146,297 @@ public sealed partial class XivDataAnalyzer using var reader = new BinaryReader(fs); // 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 - - var havokPosition = reader.ReadInt32(); - var footerPosition = reader.ReadInt32(); - - // 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; - - var tempSets = new Dictionary>(StringComparer.OrdinalIgnoreCase); - - var tempHavokDataPath = Path.Combine(Path.GetTempPath(), $"lightless_{Guid.NewGuid():N}.hkx"); - IntPtr tempHavokDataPathAnsi = IntPtr.Zero; - try { - File.WriteAllBytes(tempHavokDataPath, havokData); + _ = reader.ReadInt32(); // ignore + _ = reader.ReadInt32(); // ignore + var numAnimations = reader.ReadInt16(); // num animations + var modelId = reader.ReadInt16(); // modelid - if (!File.Exists(tempHavokDataPath)) + if (numAnimations < 0 || numAnimations > 1000) { - _logger.LogTrace("Temporary havok file did not exist when attempting to load: {path}", tempHavokDataPath); + _logger.LogWarning("PAP file {hash} has invalid animation count {count}, skipping", hash, numAnimations); return null; } - tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath); + var type = reader.ReadByte(); // type + if (type != 0) + return null; // not human - 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 - }; + _ = reader.ReadByte(); // variant + _ = reader.ReadInt32(); // ignore - var resource = hkSerializeUtil.LoadFromFile((byte*)tempHavokDataPathAnsi, null, loadoptions); - if (resource == null) + var havokPosition = reader.ReadInt32(); + var footerPosition = reader.ReadInt32(); + + if (havokPosition <= 0 || footerPosition <= havokPosition || + footerPosition > fs.Length || havokPosition >= fs.Length) { - _logger.LogWarning("Havok resource was null after loading from {path}", tempHavokDataPath); + _logger.LogWarning("PAP file {hash} has invalid offsets (havok={havok}, footer={footer}, length={length})", + hash, havokPosition, footerPosition, fs.Length); return null; } - var rootLevelName = @"hkRootLevelContainer"u8; - fixed (byte* n1 = rootLevelName) + var havokDataSizeLong = (long)footerPosition - havokPosition; + if (havokDataSizeLong <= 8 || havokDataSizeLong > int.MaxValue) { - var container = (hkRootLevelContainer*)resource->GetContentsPointer(n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry()); - if (container == null) - return null; + _logger.LogWarning("PAP file {hash} has invalid Havok data size {size}", hash, havokDataSizeLong); + return null; + } - var animationName = @"hkaAnimationContainer"u8; - fixed (byte* n2 = animationName) + var havokDataSize = (int)havokDataSizeLong; + + reader.BaseStream.Position = havokPosition; + + var havokData = new byte[havokDataSize]; + var bytesRead = reader.Read(havokData, 0, havokDataSize); + if (bytesRead != havokDataSize) + { + _logger.LogWarning("PAP file {hash}: Expected to read {expected} bytes but got {actual}", + hash, havokDataSize, bytesRead); + return null; + } + + if (havokData.Length < 8) + return null; + + var tempSets = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + var tempFileName = $"lightless_pap_{Guid.NewGuid():N}_{hash.Substring(0, Math.Min(8, hash.Length))}.hkx"; + var tempHavokDataPath = Path.Combine(Path.GetTempPath(), tempFileName); + IntPtr tempHavokDataPathAnsi = IntPtr.Zero; + + try + { + var tempDir = Path.GetDirectoryName(tempHavokDataPath); + if (!Directory.Exists(tempDir)) { - var animContainer = (hkaAnimationContainer*)container->findObjectByName(n2, null); - if (animContainer == null) - return null; + _logger.LogWarning("Temp directory {dir} doesn't exist", tempDir); + return null; + } - for (int i = 0; i < animContainer->Bindings.Length; i++) + File.WriteAllBytes(tempHavokDataPath, havokData); + + if (!File.Exists(tempHavokDataPath)) + { + _logger.LogWarning("Temporary havok file was not created at {path}", tempHavokDataPath); + return null; + } + + var writtenFileInfo = new FileInfo(tempHavokDataPath); + if (writtenFileInfo.Length != havokData.Length) + { + _logger.LogWarning("Written temp file size mismatch: expected {expected}, got {actual}", + havokData.Length, writtenFileInfo.Length); + File.Delete(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 + { + Storage = (int)hkSerializeUtil.LoadOptionBits.Default + }; + + hkResource* resource = null; + try + { + resource = hkSerializeUtil.LoadFromFile((byte*)tempHavokDataPathAnsi, null, loadoptions); + } + catch (SEHException ex) + { + _logger.LogError(ex, "SEH exception loading Havok file from {path} (hash={hash}). Native error code: 0x{code:X}", + tempHavokDataPath, hash, ex.ErrorCode); + return null; + } + + if (resource == null) + { + _logger.LogDebug("Havok resource was null after loading from {path} (hash={hash})", tempHavokDataPath, hash); + return null; + } + + if ((nint)resource == nint.Zero || !IsValidPointer((IntPtr)resource)) + { + _logger.LogDebug("Havok resource pointer is invalid (hash={hash})", hash); + return null; + } + + var rootLevelName = @"hkRootLevelContainer"u8; + fixed (byte* n1 = rootLevelName) + { + var container = (hkRootLevelContainer*)resource->GetContentsPointer(n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry()); + if (container == null) { - var binding = animContainer->Bindings[i].ptr; - if (binding == null) - continue; + _logger.LogDebug("hkRootLevelContainer is null (hash={hash})", hash); + return null; + } - var rawSkel = binding->OriginalSkeletonName.String; - var skeletonKey = CanonicalizeSkeletonKey(rawSkel); - if (string.IsNullOrEmpty(skeletonKey)) - continue; + if ((nint)container == nint.Zero || !IsValidPointer((IntPtr)container)) + { + _logger.LogDebug("hkRootLevelContainer pointer is invalid (hash={hash})", hash); + return null; + } - var boneTransform = binding->TransformTrackToBoneIndices; - if (boneTransform.Length <= 0) - continue; - - if (!tempSets.TryGetValue(skeletonKey, out var set)) + var animationName = @"hkaAnimationContainer"u8; + fixed (byte* n2 = animationName) + { + var animContainer = (hkaAnimationContainer*)container->findObjectByName(n2, null); + if (animContainer == null) { - set = []; - tempSets[skeletonKey] = set; + _logger.LogDebug("hkaAnimationContainer is null (hash={hash})", hash); + return null; } - for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++) + if ((nint)animContainer == nint.Zero || !IsValidPointer((IntPtr)animContainer)) { - var v = boneTransform[boneIdx]; - if (v < 0) continue; - set.Add((ushort)v); + _logger.LogDebug("hkaAnimationContainer pointer is invalid (hash={hash})", hash); + return null; + } + + if (animContainer->Bindings.Length < 0 || animContainer->Bindings.Length > 10000) + { + _logger.LogDebug("Invalid bindings count {count} (hash={hash})", animContainer->Bindings.Length, hash); + return null; + } + + for (int i = 0; i < animContainer->Bindings.Length; i++) + { + var binding = animContainer->Bindings[i].ptr; + if (binding == null) + continue; + + if ((nint)binding == nint.Zero || !IsValidPointer((IntPtr)binding)) + { + _logger.LogDebug("Skipping invalid binding at index {index} (hash={hash})", i, hash); + continue; + } + + var rawSkel = binding->OriginalSkeletonName.String; + var skeletonKey = CanonicalizeSkeletonKey(rawSkel); + if (string.IsNullOrEmpty(skeletonKey)) + continue; + + var boneTransform = binding->TransformTrackToBoneIndices; + if (boneTransform.Length <= 0 || boneTransform.Length > 10000) + { + _logger.LogDebug("Invalid bone transform length {length} for skeleton {skel} (hash={hash})", + boneTransform.Length, skeletonKey, hash); + continue; + } + + if (!tempSets.TryGetValue(skeletonKey, out var set)) + { + set = []; + tempSets[skeletonKey] = set; + } + + for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++) + { + var v = boneTransform[boneIdx]; + if (v < 0 || v > ushort.MaxValue) + continue; + set.Add((ushort)v); + } } } } } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Could not load havok file in {path}", tempHavokDataPath); - return null; - } - finally - { - if (tempHavokDataPathAnsi != IntPtr.Zero) - Marshal.FreeHGlobal(tempHavokDataPathAnsi); - - try + catch (SEHException ex) { - if (File.Exists(tempHavokDataPath)) - File.Delete(tempHavokDataPath); + _logger.LogError(ex, "SEH exception processing PAP file {hash} from {path}. Error code: 0x{code:X}", + hash, tempHavokDataPath, ex.ErrorCode); + return null; } catch (Exception ex) { - _logger.LogTrace(ex, "Could not delete temporary havok file: {path}", tempHavokDataPath); + _logger.LogError(ex, "Managed exception loading havok file {hash} from {path}", hash, tempHavokDataPath); + return null; } + finally + { + if (tempHavokDataPathAnsi != IntPtr.Zero) + Marshal.FreeHGlobal(tempHavokDataPathAnsi); + + int retryCount = 3; + while (retryCount > 0 && File.Exists(tempHavokDataPath)) + { + try + { + File.Delete(tempHavokDataPath); + break; + } + catch (IOException ex) + { + retryCount--; + if (retryCount == 0) + { + _logger.LogDebug(ex, "Failed to delete temporary havok file after retries: {path}", tempHavokDataPath); + } + else + { + Thread.Sleep(50); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Unexpected error deleting temporary havok file: {path}", tempHavokDataPath); + break; + } + } + } + + if (tempSets.Count == 0) + { + _logger.LogDebug("No bone sets found in PAP file (hash={hash})", hash); + return null; + } + + var output = new Dictionary>(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; + + if (persistToConfig) + _configService.Save(); + + return output; } - - if (tempSets.Count == 0) - return null; - - var output = new Dictionary>(tempSets.Count, StringComparer.OrdinalIgnoreCase); - foreach (var (key, set) in tempSets) + catch (Exception ex) { - if (set.Count == 0) continue; - - var list = set.ToList(); - list.Sort(); - output[key] = list; - } - - if (output.Count == 0) + _logger.LogError(ex, "Outer exception reading PAP file (hash={hash})", hash); return null; + } + } - _configService.Current.BonesDictionary[hash] = output; + private static bool IsValidPointer(IntPtr ptr) + { + if (ptr == IntPtr.Zero) + return false; - if (persistToConfig) - _configService.Save(); - - return output; + try + { + _ = Marshal.ReadByte(ptr); + return true; + } + catch + { + return false; + } } -- 2.49.1 From ce28799db388796ff50a0b55da26eaafab5fd260 Mon Sep 17 00:00:00 2001 From: defnotken Date: Mon, 5 Jan 2026 20:46:14 -0600 Subject: [PATCH 54/87] More checks for animations and bones. --- LightlessSync/Services/XivDataAnalyzer.cs | 51 ++++++++++++++++++++--- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/LightlessSync/Services/XivDataAnalyzer.cs b/LightlessSync/Services/XivDataAnalyzer.cs index cd3b20c..65d9346 100644 --- a/LightlessSync/Services/XivDataAnalyzer.cs +++ b/LightlessSync/Services/XivDataAnalyzer.cs @@ -202,7 +202,7 @@ public sealed partial class XivDataAnalyzer var tempSets = new Dictionary>(StringComparer.OrdinalIgnoreCase); - var tempFileName = $"lightless_pap_{Guid.NewGuid():N}_{hash.Substring(0, Math.Min(8, hash.Length))}.hkx"; + var tempFileName = $"lightless_pap_{Guid.NewGuid():N}_{hash[..Math.Min(8, hash.Length)]}.hkx"; var tempHavokDataPath = Path.Combine(Path.GetTempPath(), tempFileName); IntPtr tempHavokDataPathAnsi = IntPtr.Zero; @@ -215,7 +215,16 @@ public sealed partial class XivDataAnalyzer return null; } - File.WriteAllBytes(tempHavokDataPath, havokData); + // Write the file with explicit error handling + try + { + File.WriteAllBytes(tempHavokDataPath, havokData); + } + catch (Exception writeEx) + { + _logger.LogError(writeEx, "Failed to write temporary Havok file to {path}", tempHavokDataPath); + return null; + } if (!File.Exists(tempHavokDataPath)) { @@ -228,7 +237,26 @@ public sealed partial class XivDataAnalyzer { _logger.LogWarning("Written temp file size mismatch: expected {expected}, got {actual}", havokData.Length, writtenFileInfo.Length); - File.Delete(tempHavokDataPath); + try { File.Delete(tempHavokDataPath); } catch { } + return null; + } + + Thread.Sleep(10); // stabilize file system + + try + { + using var testStream = File.OpenRead(tempHavokDataPath); + if (testStream.Length != havokData.Length) + { + _logger.LogWarning("File verification failed: length mismatch after write"); + try { File.Delete(tempHavokDataPath); } catch { } + return null; + } + } + catch (Exception readEx) + { + _logger.LogError(readEx, "Cannot read back temporary file at {path}", tempHavokDataPath); + try { File.Delete(tempHavokDataPath); } catch { } return null; } @@ -245,18 +273,31 @@ public sealed partial class XivDataAnalyzer hkResource* resource = null; try { + if (tempHavokDataPathAnsi == IntPtr.Zero) + { + _logger.LogError("Failed to allocate ANSI string for path"); + return null; + } + resource = hkSerializeUtil.LoadFromFile((byte*)tempHavokDataPathAnsi, null, loadoptions); } catch (SEHException ex) { - _logger.LogError(ex, "SEH exception loading Havok file from {path} (hash={hash}). Native error code: 0x{code:X}", + _logger.LogError(ex, "SEH exception loading Havok file from {path} (hash={hash}). Native error code: 0x{code:X}. This may indicate a corrupted PAP file or incompatible Havok format.", tempHavokDataPath, hash, ex.ErrorCode); return null; } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected exception loading Havok file from {path} (hash={hash})", + tempHavokDataPath, hash); + return null; + } if (resource == null) { - _logger.LogDebug("Havok resource was null after loading from {path} (hash={hash})", tempHavokDataPath, hash); + _logger.LogDebug("Havok resource was null after loading from {path} (hash={hash}). File may be corrupted or in an unsupported format.", + tempHavokDataPath, hash); return null; } -- 2.49.1 From 0e24da75d5baf5dbf5b817353320f9c5294f8e0b Mon Sep 17 00:00:00 2001 From: azyges Date: Tue, 6 Jan 2026 11:57:01 +0900 Subject: [PATCH 55/87] MEOW MEOW MEOW --- .../PlayerData/Pairs/PairHandlerAdapter.cs | 6 +- .../ActorTracking/ActorObjectService.cs | 29 +- LightlessSync/Services/DalamudUtilService.cs | 335 ++++++++---------- 3 files changed, 176 insertions(+), 194 deletions(-) diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index fd1db53..b392e62 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -2826,7 +2826,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa HandleVisibilityLoss(logChange: false); } - private static bool TryResolveDescriptorHash(ActorObjectService.ActorDescriptor descriptor, out string hashedCid) + private bool TryResolveDescriptorHash(ActorObjectService.ActorDescriptor descriptor, out string hashedCid) { hashedCid = descriptor.HashedContentId ?? string.Empty; if (!string.IsNullOrEmpty(hashedCid)) @@ -2835,8 +2835,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (descriptor.ObjectKind != DalamudObjectKind.Player || descriptor.Address == nint.Zero) return false; - hashedCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(descriptor.Address); - return !string.IsNullOrEmpty(hashedCid); + return _dalamudUtil.TryGetHashedCIDFromAddress(descriptor.Address, out hashedCid) + && !string.IsNullOrEmpty(hashedCid); } private void UpdateLastKnownActor(ActorObjectService.ActorDescriptor descriptor) diff --git a/LightlessSync/Services/ActorTracking/ActorObjectService.cs b/LightlessSync/Services/ActorTracking/ActorObjectService.cs index e443496..a00839e 100644 --- a/LightlessSync/Services/ActorTracking/ActorObjectService.cs +++ b/LightlessSync/Services/ActorTracking/ActorObjectService.cs @@ -93,6 +93,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS } RefreshTrackedActors(force: true); }); + _mediator.Subscribe(this, _ => ClearTrackingState()); } private bool IsZoning => _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51]; @@ -342,18 +343,8 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS public Task StopAsync(CancellationToken cancellationToken) { DisposeHooks(); - _activePlayers.Clear(); - _gposePlayers.Clear(); - _actorsByHash.Clear(); - _actorsByName.Clear(); - _pendingHashResolutions.Clear(); + ClearTrackingState(); _mediator.UnsubscribeAll(this); - lock (_playerRelatedHandlerLock) - { - _playerRelatedHandlers.Clear(); - } - Volatile.Write(ref _snapshot, ActorSnapshot.Empty); - Volatile.Write(ref _gposeSnapshot, GposeSnapshot.Empty); return Task.CompletedTask; } @@ -1077,6 +1068,22 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS } } + private void ClearTrackingState() + { + _activePlayers.Clear(); + _gposePlayers.Clear(); + _actorsByHash.Clear(); + _actorsByName.Clear(); + _pendingHashResolutions.Clear(); + lock (_playerRelatedHandlerLock) + { + _playerRelatedHandlers.Clear(); + } + Volatile.Write(ref _snapshot, ActorSnapshot.Empty); + Volatile.Write(ref _gposeSnapshot, GposeSnapshot.Empty); + _nextRefreshAllowed = DateTime.MinValue; + } + public void Dispose() { DisposeHooks(); diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 5da96bb..672f1b5 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -22,10 +22,8 @@ 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; @@ -229,6 +227,28 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber _ = RunOnFrameworkThread(ReleaseFocusUnsafe); } + public void TargetPlayerByAddress(nint address) + { + if (address == nint.Zero) return; + if (_clientState.IsPvP) return; + + _ = RunOnFrameworkThread(() => + { + var gameObject = CreateGameObject(address); + if (gameObject is null) return; + + var useFocusTarget = _configService.Current.UseFocusTarget; + if (useFocusTarget) + { + _targetManager.FocusTarget = gameObject; + } + else + { + _targetManager.Target = gameObject; + } + }); + } + private void FocusPairUnsafe(nint address, PairUniqueIdentifier pairIdent) { var target = CreateGameObject(address); @@ -634,6 +654,37 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber return true; } + public bool TryGetHashedCIDFromAddress(nint address, out string hashedCid) + { + hashedCid = string.Empty; + if (address == nint.Zero) + return false; + + if (_framework.IsInFrameworkUpdateThread) + { + return TryGetHashedCIDFromAddressInternal(address, out hashedCid); + } + + var result = _framework.RunOnFrameworkThread(() => + { + var success = TryGetHashedCIDFromAddressInternal(address, out var resolved); + return (success, resolved); + }).GetAwaiter().GetResult(); + + hashedCid = result.resolved; + return result.success; + } + + private bool TryGetHashedCIDFromAddressInternal(nint address, out string hashedCid) + { + hashedCid = string.Empty; + var player = _objectTable.CreateObjectReference(address) as IPlayerCharacter; + if (player == null || player.Address != address) + return false; + + return TryGetHashedCID(player, out hashedCid); + } + public unsafe static string GetHashedCIDFromPlayerPointer(nint ptr) { return ((BattleChara*)ptr)->Character.ContentId.ToString().GetHash256(); @@ -845,43 +896,33 @@ 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; - var token = ct ?? CancellationToken.None; + if (ct == null) + ct = CancellationToken.None; const int tick = 250; - const int initialSettle = 50; - - var sw = Stopwatch.StartNew(); - + 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; - await Task.Delay(initialSettle, token).ConfigureAwait(false); - - while (!token.IsCancellationRequested - && sw.ElapsedMilliseconds < timeOut - && await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false)) + while ((!ct.Value.IsCancellationRequested) + && curWaitTime < timeOut + && await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false)) // 0b100000000000 is "still rendering" or something { logger.LogTrace("[{redrawId}] Waiting for {handler} to finish drawing", redrawId, handler); - await Task.Delay(tick, token).ConfigureAwait(false); + curWaitTime += tick; + await Task.Delay(tick, ct.Value).ConfigureAwait(true); } - logger.LogTrace("[{redrawId}] Finished drawing after {ms}ms", redrawId, sw.ElapsedMilliseconds); + logger.LogTrace("[{redrawId}] Finished drawing after {curWaitTime}ms", redrawId, curWaitTime); } - catch (OperationCanceledException) - { - // ignore - } - catch (Exception ex) + catch (AccessViolationException ex) { logger.LogWarning(ex, "Error accessing {handler}, object does not exist anymore?", handler); } @@ -931,109 +972,37 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber return WorldData.Value.TryGetValue(worldId, out var worldName) ? worldName : null; } - public void TargetPlayerByAddress(nint address) - { - if (address == nint.Zero) return; - if (_clientState.IsPvP) return; - - _ = RunOnFrameworkThread(() => - { - var gameObject = CreateGameObject(address); - if (gameObject is null) return; - - var useFocusTarget = _configService.Current.UseFocusTarget; - if (useFocusTarget) - { - _targetManager.FocusTarget = gameObject; - } - else - { - _targetManager.Target = gameObject; - } - }); - } - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool IsBadReadPtr(IntPtr ptr, UIntPtr size); - - private static bool IsValidPointer(nint ptr, int size = 8) - { - if (ptr == nint.Zero) - return false; - - try - { - if (!Util.IsWine()) - { - return !IsBadReadPtr(ptr, (UIntPtr)size); - } - return ptr != nint.Zero && (ptr % IntPtr.Size) == 0; - } - catch - { - return false; - } - } - private unsafe void CheckCharacterForDrawing(nint address, string characterName) { - if (address == nint.Zero) - return; - - if (!IsValidPointer(address)) - { - _logger.LogDebug("Invalid pointer for character {name} at {addr}", characterName, address.ToString("X")); - return; - } - var gameObj = (GameObject*)address; - - if (gameObj == null) - return; - - if (!_objectTable.Any(o => o?.Address == address)) - { - _logger.LogDebug("Character {name} at {addr} no longer in object table", characterName, address.ToString("X")); - return; - } - - if (gameObj->ObjectKind == 0) - return; - var drawObj = gameObj->DrawObject; bool isDrawing = false; bool isDrawingChanged = false; - - if ((nint)drawObj != IntPtr.Zero && IsValidPointer((nint)drawObj)) + if ((nint)drawObj != IntPtr.Zero) { isDrawing = gameObj->RenderFlags == (VisibilityFlags)0b100000000000; - if (!isDrawing) { - var charBase = (CharacterBase*)drawObj; - if (charBase != null && IsValidPointer((nint)charBase)) + isDrawing = ((CharacterBase*)drawObj)->HasModelInSlotLoaded != 0; + if (!isDrawing) { - isDrawing = charBase->HasModelInSlotLoaded != 0; - if (!isDrawing) + isDrawing = ((CharacterBase*)drawObj)->HasModelFilesInSlotLoaded != 0; + if (isDrawing && !string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal) + && !string.Equals(_lastGlobalBlockReason, "HasModelFilesInSlotLoaded", StringComparison.Ordinal)) { - isDrawing = charBase->HasModelFilesInSlotLoaded != 0; - if (isDrawing && !string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal) - && !string.Equals(_lastGlobalBlockReason, "HasModelFilesInSlotLoaded", StringComparison.Ordinal)) - { - _lastGlobalBlockPlayer = characterName; - _lastGlobalBlockReason = "HasModelFilesInSlotLoaded"; - isDrawingChanged = true; - } + _lastGlobalBlockPlayer = characterName; + _lastGlobalBlockReason = "HasModelFilesInSlotLoaded"; + isDrawingChanged = true; } - else + } + else + { + if (!string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal) + && !string.Equals(_lastGlobalBlockReason, "HasModelInSlotLoaded", StringComparison.Ordinal)) { - if (!string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal) - && !string.Equals(_lastGlobalBlockReason, "HasModelInSlotLoaded", StringComparison.Ordinal)) - { - _lastGlobalBlockPlayer = characterName; - _lastGlobalBlockReason = "HasModelInSlotLoaded"; - isDrawingChanged = true; - } + _lastGlobalBlockPlayer = characterName; + _lastGlobalBlockReason = "HasModelInSlotLoaded"; + isDrawingChanged = true; } } } @@ -1064,12 +1033,8 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber private unsafe void FrameworkOnUpdateInternal() { - if (!_clientState.IsLoggedIn || _objectTable.LocalPlayer == null) - { - return; - } - - if ((_objectTable.LocalPlayer?.IsDead ?? false) && _condition[ConditionFlag.BoundByDuty]) + var localPlayer = _objectTable.LocalPlayer; + if ((localPlayer?.IsDead ?? false) && _condition[ConditionFlag.BoundByDuty]) { return; } @@ -1079,6 +1044,44 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber _performanceCollector.LogPerformance(this, $"FrameworkOnUpdateInternal+{(isNormalFrameworkUpdate ? "Regular" : "Delayed")}", () => { IsAnythingDrawing = false; + + if (!isNormalFrameworkUpdate) + { + if (localPlayer != null && !IsLoggedIn) + { + _logger.LogDebug("Logged in"); + IsLoggedIn = true; + _lastZone = _clientState.TerritoryType; + _lastWorldId = (ushort)localPlayer.CurrentWorld.RowId; + _cid = RebuildCID(); + Mediator.Publish(new DalamudLoginMessage()); + } + else if (localPlayer == null && IsLoggedIn) + { + _logger.LogDebug("Logged out"); + IsLoggedIn = false; + _lastWorldId = 0; + Mediator.Publish(new DalamudLogoutMessage()); + } + + if (_gameConfig != null + && _gameConfig.TryGet(Dalamud.Game.Config.SystemConfigOption.LodType_DX11, out bool lodEnabled)) + { + IsLodEnabled = lodEnabled; + } + + if (IsInCombat || IsPerforming || IsInInstance) + Mediator.Publish(new FrameworkUpdateMessage()); + + Mediator.Publish(new DelayedFrameworkUpdateMessage()); + + _delayedFrameworkUpdateCheck = DateTime.UtcNow; + } + + if (!_clientState.IsLoggedIn || localPlayer == null) + { + return; + } _performanceCollector.LogPerformance(this, $"TrackedActorsToState", () => { @@ -1087,40 +1090,46 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber _actorObjectService.RefreshTrackedActors(); } - var playerDescriptors = _actorObjectService.PlayerDescriptors; - var descriptorCount = playerDescriptors.Count; - - for (var i = 0; i < descriptorCount; i++) + if (_clientState.IsLoggedIn && localPlayer != null) { - if (i >= playerDescriptors.Count) - break; - - var actor = playerDescriptors[i]; - - var playerAddress = actor.Address; - if (playerAddress == nint.Zero || !IsValidPointer(playerAddress)) - continue; - - if (actor.ObjectIndex >= 200) - continue; - - if (_blockedCharacterHandler.IsCharacterBlocked(playerAddress, actor.ObjectIndex, out bool firstTime) && firstTime) + var playerDescriptors = _actorObjectService.PlayerDescriptors; + for (var i = 0; i < playerDescriptors.Count; i++) { - _logger.LogTrace("Skipping character {addr}, blocked/muted", playerAddress.ToString("X")); - continue; - } + var actor = playerDescriptors[i]; - if (!IsAnythingDrawing) - { - if (!_objectTable.Any(o => o?.Address == playerAddress)) + var playerAddress = actor.Address; + if (playerAddress == nint.Zero) + continue; + + if (actor.ObjectIndex >= 200) + continue; + + var obj = _objectTable[actor.ObjectIndex]; + if (obj is not IPlayerCharacter player || player.Address != playerAddress) + continue; + + if (_blockedCharacterHandler.IsCharacterBlocked(playerAddress, actor.ObjectIndex, out bool firstTime) && firstTime) { + _logger.LogTrace("Skipping character {addr}, blocked/muted", playerAddress.ToString("X")); continue; } - CheckCharacterForDrawing(playerAddress, actor.Name); + if (!IsAnythingDrawing) + { + var charaName = player.Name.TextValue; + if (string.IsNullOrEmpty(charaName)) + { + charaName = actor.Name; + } - if (IsAnythingDrawing) + CheckCharacterForDrawing(playerAddress, charaName); + if (IsAnythingDrawing) + break; + } + else + { break; + } } } }); @@ -1246,7 +1255,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber } - var localPlayer = _objectTable.LocalPlayer; if (localPlayer != null) { _classJobId = localPlayer.ClassJob.RowId; @@ -1268,39 +1276,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber Mediator.Publish(new FrameworkUpdateMessage()); Mediator.Publish(new PriorityFrameworkUpdateMessage()); - - if (isNormalFrameworkUpdate) - return; - - if (localPlayer != null && !IsLoggedIn) - { - _logger.LogDebug("Logged in"); - IsLoggedIn = true; - _lastZone = _clientState.TerritoryType; - _lastWorldId = (ushort)localPlayer.CurrentWorld.RowId; - _cid = RebuildCID(); - Mediator.Publish(new DalamudLoginMessage()); - } - else if (localPlayer == null && IsLoggedIn) - { - _logger.LogDebug("Logged out"); - IsLoggedIn = false; - _lastWorldId = 0; - Mediator.Publish(new DalamudLogoutMessage()); - } - - if (_gameConfig != null - && _gameConfig.TryGet(Dalamud.Game.Config.SystemConfigOption.LodType_DX11, out bool lodEnabled)) - { - IsLodEnabled = lodEnabled; - } - - if (IsInCombat || IsPerforming || IsInInstance) - Mediator.Publish(new FrameworkUpdateMessage()); - - Mediator.Publish(new DelayedFrameworkUpdateMessage()); - - _delayedFrameworkUpdateCheck = DateTime.UtcNow; }); } @@ -1330,4 +1305,4 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber onExit(); } } -} \ No newline at end of file +} -- 2.49.1 From 59ed03a825ff0fdfd98ead7cd8c34dd0370c4ac8 Mon Sep 17 00:00:00 2001 From: azyges Date: Tue, 6 Jan 2026 13:27:24 +0900 Subject: [PATCH 56/87] replace with log in/out flags --- LightlessSync/Services/DalamudUtilService.cs | 68 ++++++++++++++------ 1 file changed, 48 insertions(+), 20 deletions(-) diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 672f1b5..45fb182 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -867,9 +867,12 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber { _logger.LogInformation("Starting DalamudUtilService"); _framework.Update += FrameworkOnUpdate; - if (IsLoggedIn) + _clientState.Login += OnClientLogin; + _clientState.Logout += OnClientLogout; + + if (_clientState.IsLoggedIn) { - _classJobId = _objectTable.LocalPlayer!.ClassJob.RowId; + OnClientLogin(); } _logger.LogInformation("Started DalamudUtilService"); @@ -882,6 +885,8 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber Mediator.UnsubscribeAll(this); _framework.Update -= FrameworkOnUpdate; + _clientState.Login -= OnClientLogin; + _clientState.Logout -= OnClientLogout; if (_FocusPairIdent.HasValue) { if (_framework.IsInFrameworkUpdateThread) @@ -896,6 +901,45 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber return Task.CompletedTask; } + private void OnClientLogin() + { + if (IsLoggedIn) + return; + + _ = RunOnFrameworkThread(() => + { + if (IsLoggedIn) + return; + + var localPlayer = _objectTable.LocalPlayer; + IsLoggedIn = true; + _lastZone = _clientState.TerritoryType; + if (localPlayer != null) + { + _lastWorldId = (ushort)localPlayer.CurrentWorld.RowId; + _classJobId = localPlayer.ClassJob.RowId; + } + + _cid = RebuildCID(); + Mediator.Publish(new DalamudLoginMessage()); + }); + } + + private void OnClientLogout(int type, int code) + { + if (!IsLoggedIn) + return; + _ = RunOnFrameworkThread(() => + { + if (!IsLoggedIn) + return; + + IsLoggedIn = false; + _lastWorldId = 0; + Mediator.Publish(new DalamudLogoutMessage()); + }); + } + public async Task WaitWhileCharacterIsDrawing(ILogger logger, GameObjectHandler handler, Guid redrawId, int timeOut = 5000, CancellationToken? ct = null) { if (!_clientState.IsLoggedIn) return; @@ -1040,6 +1084,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber } bool isNormalFrameworkUpdate = DateTime.UtcNow < _delayedFrameworkUpdateCheck.AddSeconds(1); + var clientLoggedIn = _clientState.IsLoggedIn; _performanceCollector.LogPerformance(this, $"FrameworkOnUpdateInternal+{(isNormalFrameworkUpdate ? "Regular" : "Delayed")}", () => { @@ -1047,23 +1092,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber if (!isNormalFrameworkUpdate) { - if (localPlayer != null && !IsLoggedIn) - { - _logger.LogDebug("Logged in"); - IsLoggedIn = true; - _lastZone = _clientState.TerritoryType; - _lastWorldId = (ushort)localPlayer.CurrentWorld.RowId; - _cid = RebuildCID(); - Mediator.Publish(new DalamudLoginMessage()); - } - else if (localPlayer == null && IsLoggedIn) - { - _logger.LogDebug("Logged out"); - IsLoggedIn = false; - _lastWorldId = 0; - Mediator.Publish(new DalamudLogoutMessage()); - } - if (_gameConfig != null && _gameConfig.TryGet(Dalamud.Game.Config.SystemConfigOption.LodType_DX11, out bool lodEnabled)) { @@ -1078,7 +1106,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber _delayedFrameworkUpdateCheck = DateTime.UtcNow; } - if (!_clientState.IsLoggedIn || localPlayer == null) + if (!clientLoggedIn) { return; } -- 2.49.1 From 5161c6bad378777a9985d9c76be3c2d0cb0b2eda Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 6 Jan 2026 13:50:58 +0100 Subject: [PATCH 57/87] Attempt fix on crash. --- .../PlayerData/Factories/PlayerDataFactory.cs | 10 +- LightlessSync/Services/XivDataAnalyzer.cs | 185 +++++++++++------- 2 files changed, 116 insertions(+), 79 deletions(-) diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index 5e1d99e..7a21ac9 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -566,7 +566,8 @@ public class PlayerDataFactory await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false); try { - papIndices = await Task.Run(() => _modelAnalyzer.GetBoneIndicesFromPap(hash), ct) + papIndices = await _dalamudUtil + .RunOnFrameworkThread(() => _modelAnalyzer.GetBoneIndicesFromPap(hash, persistToConfig: false)) .ConfigureAwait(false); } finally @@ -577,9 +578,6 @@ public class PlayerDataFactory if (papIndices == null || papIndices.Count == 0) continue; - if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105)) - continue; - if (_logger.IsEnabled(LogLevel.Debug)) { var papBuckets = papIndices @@ -658,8 +656,8 @@ public class PlayerDataFactory return new Dictionary(StringComparer.OrdinalIgnoreCase).AsReadOnly(); } - var forwardPathsLower = forwardPaths.Length == 0 ? Array.Empty() : forwardPaths.Select(p => p.ToLowerInvariant()).ToArray(); - var reversePathsLower = reversePaths.Length == 0 ? Array.Empty() : reversePaths.Select(p => p.ToLowerInvariant()).ToArray(); + 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) diff --git a/LightlessSync/Services/XivDataAnalyzer.cs b/LightlessSync/Services/XivDataAnalyzer.cs index 997df16..f6cf933 100644 --- a/LightlessSync/Services/XivDataAnalyzer.cs +++ b/LightlessSync/Services/XivDataAnalyzer.cs @@ -2,6 +2,7 @@ using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; using FFXIVClientStructs.Havok.Animation; using FFXIVClientStructs.Havok.Common.Base.Types; +using FFXIVClientStructs.Havok.Common.Serialize.Resource; using FFXIVClientStructs.Havok.Common.Serialize.Util; using LightlessSync.FileCache; using LightlessSync.Interop.GameModel; @@ -9,6 +10,7 @@ using LightlessSync.LightlessConfiguration; using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Handlers; using Microsoft.Extensions.Logging; +using OtterGui.Text.EndObjects; using System.Collections.Concurrent; using System.Runtime.InteropServices; using System.Text.RegularExpressions; @@ -172,13 +174,18 @@ public sealed partial class XivDataAnalyzer reader.BaseStream.Position = havokPosition; var havokData = reader.ReadBytes(havokDataSize); - if (havokData.Length <= 8) + if (havokData.Length != havokDataSize) return null; + if (havokPosition < 0 || footerPosition < 0) return null; + if (havokPosition >= fs.Length) return null; + if (footerPosition > fs.Length) return null; + if (havokPosition + havokDataSizeLong > fs.Length) return null; + var tempSets = new Dictionary>(StringComparer.OrdinalIgnoreCase); var tempHavokDataPath = Path.Combine(Path.GetTempPath(), $"lightless_{Guid.NewGuid():N}.hkx"); - IntPtr tempHavokDataPathAnsi = IntPtr.Zero; + var tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath); try { @@ -190,63 +197,63 @@ public sealed partial class XivDataAnalyzer return null; } - tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath); + var pathBytes = System.Text.Encoding.ASCII.GetBytes(tempHavokDataPath + "\0"); - var loadoptions = stackalloc hkSerializeUtil.LoadOptions[1]; - loadoptions->TypeInfoRegistry = hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry(); - loadoptions->ClassNameRegistry = hkBuiltinTypeRegistry.Instance()->GetClassNameRegistry(); - loadoptions->Flags = new hkFlags + hkSerializeUtil.LoadOptions loadOptions = default; + 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) + fixed (byte* pPath = pathBytes) { - _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) + var resource = hkSerializeUtil.LoadFromFile(pPath, errorResult: null, &loadOptions); + if (resource == null) return null; - var animationName = @"hkaAnimationContainer"u8; - fixed (byte* n2 = animationName) + var rootLevelName = @"hkRootLevelContainer"u8; + fixed (byte* n1 = rootLevelName) { - var animContainer = (hkaAnimationContainer*)container->findObjectByName(n2, null); - if (animContainer == null) + var container = (hkRootLevelContainer*)resource->GetContentsPointer(n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry()); + if (container == null) return null; - for (int i = 0; i < animContainer->Bindings.Length; i++) + var animationName = @"hkaAnimationContainer"u8; + fixed (byte* n2 = animationName) { - var binding = animContainer->Bindings[i].ptr; - if (binding == null) - continue; + var animContainer = (hkaAnimationContainer*)container->findObjectByName(n2, null); + if (animContainer == null) + return null; - var rawSkel = binding->OriginalSkeletonName.String; - var skeletonKey = CanonicalizeSkeletonKey(rawSkel); - if (string.IsNullOrEmpty(skeletonKey)) - continue; - - var boneTransform = binding->TransformTrackToBoneIndices; - if (boneTransform.Length <= 0) - continue; - - if (!tempSets.TryGetValue(skeletonKey, out var set)) + for (int i = 0; i < animContainer->Bindings.Length; i++) { - set = []; - tempSets[skeletonKey] = set; - } + var binding = animContainer->Bindings[i].ptr; + if (binding == null) + continue; - for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++) - { - var v = boneTransform[boneIdx]; - if (v < 0) continue; - set.Add((ushort)v); + var rawSkel = binding->OriginalSkeletonName.String; + var skeletonKey = CanonicalizeSkeletonKey(rawSkel); + if (string.IsNullOrEmpty(skeletonKey) || string.Equals(skeletonKey, "skeleton", StringComparison.OrdinalIgnoreCase)) + skeletonKey = "__any__"; + + var boneTransform = binding->TransformTrackToBoneIndices; + if (boneTransform.Length <= 0) + continue; + + if (!tempSets.TryGetValue(skeletonKey, out var set)) + { + set = []; + tempSets[skeletonKey] = set; + } + + for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++) + { + var v = boneTransform[boneIdx]; + if (v < 0) continue; + set.Add((ushort)v); + } } } } @@ -297,7 +304,6 @@ public sealed partial class XivDataAnalyzer return output; } - public static string CanonicalizeSkeletonKey(string? raw) { if (string.IsNullOrWhiteSpace(raw)) @@ -375,41 +381,56 @@ public sealed partial class XivDataAnalyzer if (mode == AnimationValidationMode.Unsafe) return true; - var papBuckets = papBoneIndices.Keys - .Select(CanonicalizeSkeletonKey) - .Where(k => !string.IsNullOrEmpty(k)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); + var papByBucket = new Dictionary>(StringComparer.OrdinalIgnoreCase); - if (papBuckets.Count == 0) + foreach (var (rawKey, list) in papBoneIndices) + { + var key = CanonicalizeSkeletonKey(rawKey); + if (string.IsNullOrEmpty(key)) + continue; + + if (string.Equals(key, "skeleton", StringComparison.OrdinalIgnoreCase)) + key = "__any__"; + + if (!papByBucket.TryGetValue(key, out var acc)) + papByBucket[key] = acc = []; + + if (list is { Count: > 0 }) + acc.AddRange(list); + } + + foreach (var k in papByBucket.Keys.ToList()) + papByBucket[k] = papByBucket[k].Distinct().ToList(); + + if (papByBucket.Count == 0) { reason = "No skeleton bucket bindings found in the PAP"; return false; } - if (mode == AnimationValidationMode.Safe) + static bool AllIndicesOk( + HashSet available, + List indices, + bool papLikelyOneBased, + bool allowOneBasedShift, + bool allowNeighborTolerance, + out ushort missing) { - 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)) + foreach (var idx in indices) { - reason = $"Missing skeleton bucket '{bucket}' on local actor."; - return false; + if (!ContainsIndexCompat(available, idx, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance)) + { + missing = idx; + return false; + } } - var indices = papBoneIndices - .Where(kvp => string.Equals(CanonicalizeSkeletonKey(kvp.Key), bucket, StringComparison.OrdinalIgnoreCase)) - .SelectMany(kvp => kvp.Value ?? Enumerable.Empty()) - .Distinct() - .ToList(); + missing = 0; + return true; + } + foreach (var (bucket, indices) in papByBucket) + { if (indices.Count == 0) continue; @@ -423,14 +444,32 @@ public sealed partial class XivDataAnalyzer } bool papLikelyOneBased = allowOneBasedShift && (min == 1) && has1 && !has0; - foreach (var idx in indices) + if (string.Equals(bucket, "__any__", StringComparison.OrdinalIgnoreCase)) { - if (!ContainsIndexCompat(available, idx, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance)) + foreach (var (lk, ls) in localBoneSets) { - reason = $"No compatible local skeleton for PAP '{bucket}': missing bone index {idx}."; - return false; + if (AllIndicesOk(ls, indices, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance, out _)) + goto nextBucket; } + + reason = $"No compatible local skeleton bucket for generic PAP skeleton '{bucket}'. Local buckets: {string.Join(", ", localBoneSets.Keys)}"; + return false; } + + if (!localBoneSets.TryGetValue(bucket, out var available)) + { + reason = $"Missing skeleton bucket '{bucket}' on local actor."; + return false; + } + + if (!AllIndicesOk(available, indices, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance, out var missing)) + { + reason = $"No compatible local skeleton for PAP '{bucket}': missing bone index {missing}."; + return false; + } + + nextBucket: + ; } return true; -- 2.49.1 From 9167bb1afd30fa68fca335ff9ea9fd82261e537d Mon Sep 17 00:00:00 2001 From: Tsubasa Date: Tue, 6 Jan 2026 12:51:29 +0000 Subject: [PATCH 58/87] i18n init (#135) shouldnt break anything? Co-authored-by: Tsubasahane Reviewed-on: https://git.lightless-sync.org/Lightless-Sync/LightlessClient/pulls/135 Co-authored-by: Tsubasa Co-committed-by: Tsubasa --- LightlessAPI | 2 +- LightlessSync/LightlessSync.csproj | 11 +- .../LightlessSync.csproj.DotSettings | 3 + LightlessSync/Localization/Strings.cs | 44 ----- LightlessSync/Localization/de.json | 46 ----- LightlessSync/Localization/fr.json | 46 ----- .../Resources/LocalizationExtention.cs | 9 + LightlessSync/Resources/Resources.Designer.cs | 170 ++++++++++++++++++ LightlessSync/Resources/Resources.de.resx | 47 +++++ LightlessSync/Resources/Resources.fr.resx | 47 +++++ LightlessSync/Resources/Resources.resx | 57 ++++++ LightlessSync/Resources/Resources.zh.resx | 20 +++ LightlessSync/UI/IntroUI.cs | 48 ++--- LightlessSync/UI/SettingsUi.cs | 2 +- LightlessSync/UI/UISharedService.cs | 7 - 15 files changed, 383 insertions(+), 176 deletions(-) create mode 100644 LightlessSync/LightlessSync.csproj.DotSettings delete mode 100644 LightlessSync/Localization/Strings.cs delete mode 100644 LightlessSync/Localization/de.json delete mode 100644 LightlessSync/Localization/fr.json create mode 100644 LightlessSync/Resources/LocalizationExtention.cs create mode 100644 LightlessSync/Resources/Resources.Designer.cs create mode 100644 LightlessSync/Resources/Resources.de.resx create mode 100644 LightlessSync/Resources/Resources.fr.resx create mode 100644 LightlessSync/Resources/Resources.resx create mode 100644 LightlessSync/Resources/Resources.zh.resx diff --git a/LightlessAPI b/LightlessAPI index 4ecd537..c3caa7e 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit 4ecd5375e63082f44b841bcba38d5dd3f4a2a79b +Subproject commit c3caa7e25cf17fd52c4765bf051ec37c8fd92082 diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 938d413..b0b7b8e 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -24,6 +24,15 @@ + + PublicResXFileCodeGenerator + Resources.Designer.cs + + + True + True + Resources.resx + @@ -68,8 +77,6 @@ - - diff --git a/LightlessSync/LightlessSync.csproj.DotSettings b/LightlessSync/LightlessSync.csproj.DotSettings new file mode 100644 index 0000000..71a1e0f --- /dev/null +++ b/LightlessSync/LightlessSync.csproj.DotSettings @@ -0,0 +1,3 @@ + + Yes + Pessimistic \ No newline at end of file diff --git a/LightlessSync/Localization/Strings.cs b/LightlessSync/Localization/Strings.cs deleted file mode 100644 index 56f938b..0000000 --- a/LightlessSync/Localization/Strings.cs +++ /dev/null @@ -1,44 +0,0 @@ -using CheapLoc; - -namespace LightlessSync.Localization; - -public static class Strings -{ - public static ToSStrings ToS { get; set; } = new(); - - public class ToSStrings - { - public readonly string AgreeLabel = Loc.Localize("AgreeLabel", "I agree"); - public readonly string AgreementLabel = Loc.Localize("AgreementLabel", "Agreement of Usage of Service"); - public readonly string ButtonWillBeAvailableIn = Loc.Localize("ButtonWillBeAvailableIn", "'I agree' button will be available in"); - public readonly string LanguageLabel = Loc.Localize("LanguageLabel", "Language"); - - public readonly string Paragraph1 = Loc.Localize("Paragraph1", - "All of the mod files currently active on your character as well as your current character state will be uploaded to the service you registered yourself at automatically. " + - "The plugin will exclusively upload the necessary mod files and not the whole mod."); - - public readonly string Paragraph2 = Loc.Localize("Paragraph2", - "If you are on a data capped internet connection, higher fees due to data usage depending on the amount of downloaded and uploaded mod files might occur. " + - "Mod files will be compressed on up- and download to save on bandwidth usage. Due to varying up- and download speeds, changes in characters might not be visible immediately. " + - "Files present on the service that already represent your active mod files will not be uploaded again."); - - public readonly string Paragraph3 = Loc.Localize("Paragraph3", - "The mod files you are uploading are confidential and will not be distributed to parties other than the ones who are requesting the exact same mod files. " + - "Please think about who you are going to pair since it is unavoidable that they will receive and locally cache the necessary mod files that you have currently in use. " + - "Locally cached mod files will have arbitrary file names to discourage attempts at replicating the original mod."); - - public readonly string Paragraph4 = Loc.Localize("Paragraph4", - "The plugin creator tried their best to keep you secure. However, there is no guarantee for 100% security. Do not blindly pair your client with everyone."); - - public readonly string Paragraph5 = Loc.Localize("Paragraph5", - "Mod files that are saved on the service will remain on the service as long as there are requests for the files from clients. " + - "After a period of not being used, the mod files will be automatically deleted. " + - "You will also be able to wipe all the files you have personally uploaded on request. " + - "The service holds no information about which mod files belong to which mod."); - - public readonly string Paragraph6 = Loc.Localize("Paragraph6", - "This service is provided as-is. In case of abuse join the Lightless Sync Discord."); - - public readonly string ReadLabel = Loc.Localize("ReadLabel", "READ THIS CAREFULLY"); - } -} \ No newline at end of file diff --git a/LightlessSync/Localization/de.json b/LightlessSync/Localization/de.json deleted file mode 100644 index b2552cb..0000000 --- a/LightlessSync/Localization/de.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "LanguageLabel": { - "message": "Language", - "description": "ToSStrings..ctor" - }, - "AgreementLabel": { - "message": "Nutzungsbedingungen", - "description": "ToSStrings..ctor" - }, - "ReadLabel": { - "message": "BITTE LIES DIES SORGFÄLTIG", - "description": "ToSStrings..ctor" - }, - "Paragraph1": { - "message": "Alle Moddateien, die aktuell auf deinem Charakter aktiv sind und dein Charakterzustand werden automatisch zu dem Service, an dem du dich registriert hast, hochgeladen. Das Plugin wird ausschließlich die nötigen Moddateien hochladen und nicht die gesamte Modifikation.", - "description": "ToSStrings..ctor" - }, - "Paragraph2": { - "message": "Falls du mit einer getakteten Internetverbindung verbunden bist, können durch den Datentransfer von Hoch- und Runtergeladenen Moddateien höhere Kosten entstehen. Moddateien werden beim Hoch- und Runterladen komprimiert um Bandbreite zu sparen. Durch unterschiedliche Hoch- und Runterladgeschwindigkeiten ist es möglich, dass Änderungen an Charakteren nicht sofort sichtbar sind. Dateien die bereits auf dem Service existieren, werden nicht nochmals hochgeladen.", - "description": "ToSStrings..ctor" - }, - "Paragraph3": { - "message": "Die Moddateien die du hochlädst sind vertraulich und werden nicht mit anderen Nutzern geteilt, die nicht die exakt selben Dateien anfordern. Bitte überlege dir sorgfältig mit wem du deinen Identifikationscode teilst, da es unvermeidlich ist, dass die andere Person deine Moddateien erhält und lokal zwischenspeichert. Lokal zwischengespeicherte Dateien haben willkürrliche Namen um vor Versuchen abzuschrecken die originalen Moddateien aus diesen wiederherzustellen.", - "description": "ToSStrings..ctor" - }, - "Paragraph4": { - "message": "Der Ersteller des Plugins hat sein Bestes getan, um deine Sicherheit zu gewährleisten. Es gibt jedoch keine Garantie für 100%ige Sicherheit. Teile deinen Identifikationscode nicht blind mit jedem.", - "description": "ToSStrings..ctor" - }, - "Paragraph5": { - "message": "Moddateien, die auf dem Service gespeichert sind, verbleiben auf dem Service, solange es Anforderungen für diese Dateien gibt. Nach einer Zeitspanne in der die Dateien nicht verwendet wurden, werden diese automatisch gelöscht. Du hast auch die Möglichkeit manuell alle Dateien auf dem Service zu löschen. Der Service hat keine Informationen welche Moddateien zu welcher Modifikation gehören.", - "description": "ToSStrings..ctor" - }, - "Paragraph6": { - "message": "Dieser Dienst wird ohne Gewähr angeboten. Im Falle eines Missbrauchs tretet dem Lightless Sync Discord bei.", - "description": "ToSStrings..ctor" - }, - "AgreeLabel": { - "message": "Ich Stimme zu", - "description": "ToSStrings..ctor" - }, - "ButtonWillBeAvailableIn": { - "message": "\"Ich stimme zu\" Knopf verfügbar in", - "description": "ToSStrings..ctor" - } -} \ No newline at end of file diff --git a/LightlessSync/Localization/fr.json b/LightlessSync/Localization/fr.json deleted file mode 100644 index 7a2f328..0000000 --- a/LightlessSync/Localization/fr.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "LanguageLabel": { - "message": "Language", - "description": "ToSStrings..ctor" - }, - "AgreementLabel": { - "message": "Conditions d'Utilisation", - "description": "ToSStrings..ctor" - }, - "ReadLabel": { - "message": "LISEZ CES INFORMATIONS ATTENTIVEMENT", - "description": "ToSStrings..ctor" - }, - "Paragraph1": { - "message": "Tous les fichiers moddés actuellement en cours d'utilisation ainsi que le statut actuel de votre personnage vont être mix en ligne via le service sur lequel vous vous êtes automatiquement enregistré. Seuls les fichiers nécessaires seront téléversés par le plugin et non pas le mod en entier.", - "description": "ToSStrings..ctor" - }, - "Paragraph2": { - "message": "Si le débit de votre connexion internet est limité, le téléchargement et téléversement d'un grand nombre de fichiers peut entraîner des coûts supplémentaires. Les fichiers seront compressés au chargement et versement pour réduire l'impact sur votre bande passants. Selon la rapidité de vos téléchargements et téléversements, les changements ne seront peut-être pas visibles instantanément sur les personnages. Les fichiers déja présents sur le service qui correspondent à ceux de vos mods en cours d'utilisation ne seront pas remis en ligne.", - "description": "ToSStrings..ctor" - }, - "Paragraph3": { - "message": "Les fichiers que vous allez partager sont confidentiels et ne seront envoyés qu'aux utilisateurs qui feront une requête exacte de ceux-çi. Nous vous demandons de (re)considérer qui sera synchronisé avec vous, puisqu'ils recevront et stockeront inévitablement en local les fichiers nécéssaires utilisés à cet instant. Les noms des fichiers stockés localement sont changés de manière arbitraire afin de décourager toute tentative de réplication des originaux.", - "description": "ToSStrings..ctor" - }, - "Paragraph4": { - "message": "Le créateur de ce plugin a tenté de sécuriser l'application du mieux possible. Cependant, il ne peut pas garantir une protection 100% infaillible. Pour votre sécurité, ne vous synchronisez pas aveuglément et avec n'importe qui.", - "description": "ToSStrings..ctor" - }, - "Paragraph5": { - "message": "Les fichiers sauvegardés sur le service resteront en ligne tant que des utilisateurs en feront usage. Ils seront effacés automatiquement après une certaine période d'inactivité. Vous pouvez également demander l'effacement de tous les fichiers que vous avez mis en ligne vous-même. Le service en soi ne contient aucune information pouvant identifier quel fichier appartient à quel mod.", - "description": "ToSStrings..ctor" - }, - "Paragraph6": { - "message": "Ce service et ses composants vous sont fournis en l'état. En cas d'abus rejoindre le serveur Discord Lightless Sync.", - "description": "ToSStrings..ctor" - }, - "AgreeLabel": { - "message": "J'accept", - "description": "ToSStrings..ctor" - }, - "ButtonWillBeAvailableIn": { - "message": "Bouton \"J'accept\" disposible dans", - "description": "ToSStrings..ctor" - } -} \ No newline at end of file diff --git a/LightlessSync/Resources/LocalizationExtention.cs b/LightlessSync/Resources/LocalizationExtention.cs new file mode 100644 index 0000000..58fd6df --- /dev/null +++ b/LightlessSync/Resources/LocalizationExtention.cs @@ -0,0 +1,9 @@ +namespace LightlessSync.Resources; + +public static class LocalizationExtensions +{ + public static string F(this string mask, params object[] args) + { + return string.Format(mask, args); + } +} \ No newline at end of file diff --git a/LightlessSync/Resources/Resources.Designer.cs b/LightlessSync/Resources/Resources.Designer.cs new file mode 100644 index 0000000..8a909b1 --- /dev/null +++ b/LightlessSync/Resources/Resources.Designer.cs @@ -0,0 +1,170 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace LightlessSync.Resources { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("LightlessSync.Resources.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to I agree. + /// + public static string ToSStrings_AgreeLabel { + get { + return ResourceManager.GetString("ToSStrings_AgreeLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Agreement of Usage of Service. + /// + public static string ToSStrings_AgreementLabel { + get { + return ResourceManager.GetString("ToSStrings_AgreementLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to 'I agree' button will be available in. + /// + public static string ToSStrings_ButtonWillBeAvailableIn { + get { + return ResourceManager.GetString("ToSStrings_ButtonWillBeAvailableIn", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Language. + /// + public static string ToSStrings_LanguageLabel { + get { + return ResourceManager.GetString("ToSStrings_LanguageLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to All of the mod files currently active on your character as well as your current character state will be uploaded to the service you registered yourself at automatically. The plugin will exclusively upload the necessary mod files and not the whole mod.. + /// + public static string ToSStrings_Paragraph1 { + get { + return ResourceManager.GetString("ToSStrings_Paragraph1", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to If you are on a data capped internet connection, higher fees due to data usage depending on the amount of downloaded and uploaded mod files might occur. Mod files will be compressed on up- and download to save on bandwidth usage. Due to varying up- and download speeds, changes in characters might not be visible immediately. Files present on the service that already represent your active mod files will not be uploaded again.. + /// + public static string ToSStrings_Paragraph2 { + get { + return ResourceManager.GetString("ToSStrings_Paragraph2", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The mod files you are uploading are confidential and will not be distributed to parties other than the ones who are requesting the exact same mod files. Please think about who you are going to pair since it is unavoidable that they will receive and locally cache the necessary mod files that you have currently in use. Locally cached mod files will have arbitrary file names to discourage attempts at replicating the original mod.. + /// + public static string ToSStrings_Paragraph3 { + get { + return ResourceManager.GetString("ToSStrings_Paragraph3", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The plugin creator tried their best to keep you secure. However, there is no guarantee for 100% security. Do not blindly pair your client with everyone.. + /// + public static string ToSStrings_Paragraph4 { + get { + return ResourceManager.GetString("ToSStrings_Paragraph4", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Mod files that are saved on the service will remain on the service as long as there are requests for the files from clients. After a period of not being used, the mod files will be automatically deleted. You will also be able to wipe all the files you have personally uploaded on request. The service holds no information about which mod files belong to which mod.. + /// + public static string ToSStrings_Paragraph5 { + get { + return ResourceManager.GetString("ToSStrings_Paragraph5", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This service is provided as-is. In case of abuse join the Lightless Sync Discord.. + /// + public static string ToSStrings_Paragraph6 { + get { + return ResourceManager.GetString("ToSStrings_Paragraph6", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to READ THIS CAREFULLY. + /// + public static string ToSStrings_ReadLabel { + get { + return ResourceManager.GetString("ToSStrings_ReadLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Users Online. + /// + public static string Users_Online { + get { + return ResourceManager.GetString("Users_Online", resourceCulture); + } + } + } +} diff --git a/LightlessSync/Resources/Resources.de.resx b/LightlessSync/Resources/Resources.de.resx new file mode 100644 index 0000000..ac3a1f5 --- /dev/null +++ b/LightlessSync/Resources/Resources.de.resx @@ -0,0 +1,47 @@ + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Language + + + Nutzungsbedingungen + + + BITTE LIES DIES SORGFÄLTIG + + + Alle Moddateien, die aktuell auf deinem Charakter aktiv sind und dein Charakterzustand werden automatisch zu dem Service, an dem du dich registriert hast, hochgeladen. Das Plugin wird ausschließlich die nötigen Moddateien hochladen und nicht die gesamte Modifikation. + + + Falls du mit einer getakteten Internetverbindung verbunden bist, können durch den Datentransfer von Hoch- und Runtergeladenen Moddateien höhere Kosten entstehen. Moddateien werden beim Hoch- und Runterladen komprimiert um Bandbreite zu sparen. Durch unterschiedliche Hoch- und Runterladgeschwindigkeiten ist es möglich, dass Änderungen an Charakteren nicht sofort sichtbar sind. Dateien die bereits auf dem Service existieren, werden nicht nochmals hochgeladen. + + + Die Moddateien die du hochlädst sind vertraulich und werden nicht mit anderen Nutzern geteilt, die nicht die exakt selben Dateien anfordern. Bitte überlege dir sorgfältig mit wem du deinen Identifikationscode teilst, da es unvermeidlich ist, dass die andere Person deine Moddateien erhält und lokal zwischenspeichert. Lokal zwischengespeicherte Dateien haben willkürrliche Namen um vor Versuchen abzuschrecken die originalen Moddateien aus diesen wiederherzustellen. + + + Der Ersteller des Plugins hat sein Bestes getan, um deine Sicherheit zu gewährleisten. Es gibt jedoch keine Garantie für 100%ige Sicherheit. Teile deinen Identifikationscode nicht blind mit jedem. + + + Moddateien, die auf dem Service gespeichert sind, verbleiben auf dem Service, solange es Anforderungen für diese Dateien gibt. Nach einer Zeitspanne in der die Dateien nicht verwendet wurden, werden diese automatisch gelöscht. Du hast auch die Möglichkeit manuell alle Dateien auf dem Service zu löschen. Der Service hat keine Informationen welche Moddateien zu welcher Modifikation gehören. + + + Dieser Dienst wird ohne Gewähr angeboten. Im Falle eines Missbrauchs tretet dem Lightless Sync Discord bei. + + + Ich Stimme zu + + + "Ich stimme zu" Knopf verfügbar in + + \ No newline at end of file diff --git a/LightlessSync/Resources/Resources.fr.resx b/LightlessSync/Resources/Resources.fr.resx new file mode 100644 index 0000000..ab3e580 --- /dev/null +++ b/LightlessSync/Resources/Resources.fr.resx @@ -0,0 +1,47 @@ + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Language + + + Conditions d'Utilisation + + + LISEZ CES INFORMATIONS ATTENTIVEMENT + + + Tous les fichiers moddés actuellement en cours d'utilisation ainsi que le statut actuel de votre personnage vont être mix en ligne via le service sur lequel vous vous êtes automatiquement enregistré. Seuls les fichiers nécessaires seront téléversés par le plugin et non pas le mod en entier. + + + Si le débit de votre connexion internet est limité, le téléchargement et téléversement d'un grand nombre de fichiers peut entraîner des coûts supplémentaires. Les fichiers seront compressés au chargement et versement pour réduire l'impact sur votre bande passants. Selon la rapidité de vos téléchargements et téléversements, les changements ne seront peut-être pas visibles instantanément sur les personnages. Les fichiers déja présents sur le service qui correspondent à ceux de vos mods en cours d'utilisation ne seront pas remis en ligne. + + + Les fichiers que vous allez partager sont confidentiels et ne seront envoyés qu'aux utilisateurs qui feront une requête exacte de ceux-çi. Nous vous demandons de (re)considérer qui sera synchronisé avec vous, puisqu'ils recevront et stockeront inévitablement en local les fichiers nécéssaires utilisés à cet instant. Les noms des fichiers stockés localement sont changés de manière arbitraire afin de décourager toute tentative de réplication des originaux. + + + Le créateur de ce plugin a tenté de sécuriser l'application du mieux possible. Cependant, il ne peut pas garantir une protection 100% infaillible. Pour votre sécurité, ne vous synchronisez pas aveuglément et avec n'importe qui. + + + Les fichiers sauvegardés sur le service resteront en ligne tant que des utilisateurs en feront usage. Ils seront effacés automatiquement après une certaine période d'inactivité. Vous pouvez également demander l'effacement de tous les fichiers que vous avez mis en ligne vous-même. Le service en soi ne contient aucune information pouvant identifier quel fichier appartient à quel mod. + + + Ce service et ses composants vous sont fournis en l'état. En cas d'abus rejoindre le serveur Discord Lightless Sync. + + + J'accept + + + Bouton "J'accept" disposible dans + + \ No newline at end of file diff --git a/LightlessSync/Resources/Resources.resx b/LightlessSync/Resources/Resources.resx new file mode 100644 index 0000000..3576563 --- /dev/null +++ b/LightlessSync/Resources/Resources.resx @@ -0,0 +1,57 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + I agree + + + Agreement of Usage of Service + + + 'I agree' button will be available in + + + All of the mod files currently active on your character as well as your current character state will be uploaded to the service you registered yourself at automatically. The plugin will exclusively upload the necessary mod files and not the whole mod. + + + If you are on a data capped internet connection, higher fees due to data usage depending on the amount of downloaded and uploaded mod files might occur. Mod files will be compressed on up- and download to save on bandwidth usage. Due to varying up- and download speeds, changes in characters might not be visible immediately. Files present on the service that already represent your active mod files will not be uploaded again. + + + The mod files you are uploading are confidential and will not be distributed to parties other than the ones who are requesting the exact same mod files. Please think about who you are going to pair since it is unavoidable that they will receive and locally cache the necessary mod files that you have currently in use. Locally cached mod files will have arbitrary file names to discourage attempts at replicating the original mod. + + + The plugin creator tried their best to keep you secure. However, there is no guarantee for 100% security. Do not blindly pair your client with everyone. + + + Mod files that are saved on the service will remain on the service as long as there are requests for the files from clients. After a period of not being used, the mod files will be automatically deleted. You will also be able to wipe all the files you have personally uploaded on request. The service holds no information about which mod files belong to which mod. + + + This service is provided as-is. In case of abuse join the Lightless Sync Discord. + + + READ THIS CAREFULLY + + + Language + + + Users Online + + \ No newline at end of file diff --git a/LightlessSync/Resources/Resources.zh.resx b/LightlessSync/Resources/Resources.zh.resx new file mode 100644 index 0000000..c40c057 --- /dev/null +++ b/LightlessSync/Resources/Resources.zh.resx @@ -0,0 +1,20 @@ + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 语言 + + + 用户在线 + + \ No newline at end of file diff --git a/LightlessSync/UI/IntroUI.cs b/LightlessSync/UI/IntroUI.cs index 4fab7ef..53cf350 100644 --- a/LightlessSync/UI/IntroUI.cs +++ b/LightlessSync/UI/IntroUI.cs @@ -6,12 +6,12 @@ using Dalamud.Utility; using LightlessSync.FileCache; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; -using LightlessSync.Localization; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; using LightlessSync.Utils; using Microsoft.Extensions.Logging; +using System.Globalization; using System.Numerics; using System.Text.RegularExpressions; @@ -21,7 +21,7 @@ public partial class IntroUi : WindowMediatorSubscriberBase { private readonly LightlessConfigService _configService; private readonly CacheMonitor _cacheMonitor; - private readonly Dictionary _languages = new(StringComparer.Ordinal) { { "English", "en" }, { "Deutsch", "de" }, { "Français", "fr" } }; + private readonly Dictionary _languages = new(StringComparer.Ordinal) { { "English", "en" }, { "Deutsch", "de" }, { "Français", "fr" }, { "中文", "zh"} }; private readonly ServerConfigurationManager _serverConfigurationManager; private readonly DalamudUtilService _dalamudUtilService; private readonly UiSharedService _uiShared; @@ -31,7 +31,6 @@ public partial class IntroUi : WindowMediatorSubscriberBase private string _secretKey = string.Empty; private string _timeoutLabel = string.Empty; private Task? _timeoutTask; - private string[]? _tosParagraphs; private bool _useLegacyLogin = false; public IntroUi(ILogger logger, UiSharedService uiShared, LightlessConfigService configService, @@ -50,8 +49,7 @@ public partial class IntroUi : WindowMediatorSubscriberBase WindowBuilder.For(this) .SetSizeConstraints(new Vector2(600, 400), new Vector2(600, 2000)) .Apply(); - - GetToSLocalization(); + Mediator.Subscribe(this, (_) => IsOpen = false); Mediator.Subscribe(this, (_) => @@ -88,7 +86,7 @@ public partial class IntroUi : WindowMediatorSubscriberBase { for (int i = 60; i > 0; i--) { - _timeoutLabel = $"{Strings.ToS.ButtonWillBeAvailableIn} {i}s"; + _timeoutLabel = $"{Resources.Resources.ToSStrings_ButtonWillBeAvailableIn} {i}s"; await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); } }); @@ -102,44 +100,46 @@ public partial class IntroUi : WindowMediatorSubscriberBase Vector2 textSize; using (_uiShared.UidFont.Push()) { - textSize = ImGui.CalcTextSize(Strings.ToS.LanguageLabel); - ImGui.TextUnformatted(Strings.ToS.AgreementLabel); + textSize = ImGui.CalcTextSize(Resources.Resources.ToSStrings_LanguageLabel); + ImGui.TextUnformatted(Resources.Resources.ToSStrings_AgreementLabel); } ImGui.SameLine(); - var languageSize = ImGui.CalcTextSize(Strings.ToS.LanguageLabel); + var languageSize = ImGui.CalcTextSize(Resources.Resources.ToSStrings_LanguageLabel); ImGui.SetCursorPosX(ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X - languageSize.X - 80); ImGui.SetCursorPosY(ImGui.GetCursorPosY() + textSize.Y / 2 - languageSize.Y / 2); - ImGui.TextUnformatted(Strings.ToS.LanguageLabel); + ImGui.TextUnformatted(Resources.Resources.ToSStrings_LanguageLabel); ImGui.SameLine(); ImGui.SetCursorPosY(ImGui.GetCursorPosY() + textSize.Y / 2 - (languageSize.Y + ImGui.GetStyle().FramePadding.Y) / 2); ImGui.SetNextItemWidth(80); if (ImGui.Combo("", ref _currentLanguage, _languages.Keys.ToArray(), _languages.Count)) { - GetToSLocalization(_currentLanguage); + var culture = new CultureInfo(_languages.Values.ToArray()[_currentLanguage]); + CultureInfo.DefaultThreadCurrentCulture = culture; + CultureInfo.DefaultThreadCurrentUICulture = culture; } ImGui.Separator(); ImGui.SetWindowFontScale(1.5f); - string readThis = Strings.ToS.ReadLabel; + string readThis = Resources.Resources.ToSStrings_ReadLabel; textSize = ImGui.CalcTextSize(readThis); ImGui.SetCursorPosX(ImGui.GetWindowSize().X / 2 - textSize.X / 2); UiSharedService.ColorText(readThis, ImGuiColors.DalamudRed); ImGui.SetWindowFontScale(1.0f); ImGui.Separator(); - UiSharedService.TextWrapped(_tosParagraphs![0]); - UiSharedService.TextWrapped(_tosParagraphs![1]); - UiSharedService.TextWrapped(_tosParagraphs![2]); - UiSharedService.TextWrapped(_tosParagraphs![3]); - UiSharedService.TextWrapped(_tosParagraphs![4]); - UiSharedService.TextWrapped(_tosParagraphs![5]); + UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph1); + UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph2); + UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph3); + UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph4); + UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph5); + UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph6); ImGui.Separator(); if (_timeoutTask?.IsCompleted ?? true) { - if (ImGui.Button(Strings.ToS.AgreeLabel + "##toSetup")) + if (ImGui.Button(Resources.Resources.ToSStrings_AgreeLabel + "##toSetup")) { _configService.Current.AcceptedAgreement = true; _configService.Save(); @@ -349,16 +349,6 @@ public partial class IntroUi : WindowMediatorSubscriberBase } } - private void GetToSLocalization(int changeLanguageTo = -1) - { - if (changeLanguageTo != -1) - { - _uiShared.LoadLocalization(_languages.ElementAt(changeLanguageTo).Value); - } - - _tosParagraphs = [Strings.ToS.Paragraph1, Strings.ToS.Paragraph2, Strings.ToS.Paragraph3, Strings.ToS.Paragraph4, Strings.ToS.Paragraph5, Strings.ToS.Paragraph6]; - } - [GeneratedRegex("^[A-F0-9]{64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant)] private static partial Regex SecretRegex(); } \ No newline at end of file diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 9c2f1ef..96a300b 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -4812,7 +4812,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.TextColored(UIColors.Get("LightlessBlue"), _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture)); ImGui.SameLine(); - ImGui.TextUnformatted("Users Online"); + ImGui.TextUnformatted(Resources.Resources.Users_Online); ImGui.SameLine(); ImGui.TextUnformatted(")"); } diff --git a/LightlessSync/UI/UISharedService.cs b/LightlessSync/UI/UISharedService.cs index 514f31e..bbcaa44 100644 --- a/LightlessSync/UI/UISharedService.cs +++ b/LightlessSync/UI/UISharedService.cs @@ -18,7 +18,6 @@ using LightlessSync.Interop.Ipc; using LightlessSync.Interop.Ipc.Framework; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; -using LightlessSync.Localization; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.Mediator; @@ -1468,12 +1467,6 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase return false; } - public void LoadLocalization(string languageCode) - { - _localization.SetupWithLangCode(languageCode); - Strings.ToS = new Strings.ToSStrings(); - } - internal static void DistanceSeparator() { ImGuiHelpers.ScaledDummy(5); -- 2.49.1 From d8b9e9cf19d8253d2d11b67a5b66b60a24e95222 Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 6 Jan 2026 14:27:01 +0100 Subject: [PATCH 59/87] Splitting havok tasks. --- .../PlayerData/Factories/PlayerDataFactory.cs | 18 +- .../PlayerData/Pairs/PairHandlerAdapter.cs | 71 +++- LightlessSync/Services/XivDataAnalyzer.cs | 309 +++++------------- 3 files changed, 156 insertions(+), 242 deletions(-) diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index 3111b82..e8f3459 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -566,9 +566,21 @@ public class PlayerDataFactory await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false); try { - papIndices = await _dalamudUtil - .RunOnFrameworkThread(() => _modelAnalyzer.GetBoneIndicesFromPap(hash, persistToConfig: false)) - .ConfigureAwait(false); + 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 { diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index fd1db53..eded176 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -121,6 +121,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private DateTime _nextActorLookupUtc = DateTime.MinValue; private static readonly TimeSpan ActorLookupInterval = TimeSpan.FromSeconds(1); private static readonly SemaphoreSlim ActorInitializationLimiter = new(1, 1); + private static readonly SemaphoreSlim _papParseLimiter = new(1, 1); private const int FullyLoadedTimeoutMsPlayer = 30000; private const int FullyLoadedTimeoutMsOther = 5000; private readonly object _actorInitializationGate = new(); @@ -2910,13 +2911,13 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa var mode = _configService.Current.AnimationValidationMode; var allowBasedShift = _configService.Current.AnimationAllowOneBasedShift; - var allownNightIndex = _configService.Current.AnimationAllowNeighborIndexTolerance; + var allowNeighborIndex = _configService.Current.AnimationAllowNeighborIndexTolerance; if (mode == AnimationValidationMode.Unsafe || papOnly.Count == 0) return 0; var boneIndices = await _dalamudUtil.RunOnFrameworkThread( - () => _modelAnalyzer.GetSkeletonBoneIndices(handlerForApply)) + () => _modelAnalyzer.GetSkeletonBoneIndices(handlerForApply)) .ConfigureAwait(false); if (boneIndices == null || boneIndices.Count == 0) @@ -2930,47 +2931,86 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa foreach (var (rawKey, list) in boneIndices) { var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawKey); - if (string.IsNullOrEmpty(key)) continue; + if (string.IsNullOrEmpty(key) || list == null || list.Count == 0) + continue; if (!localBoneSets.TryGetValue(key, out var set)) - localBoneSets[key] = set = []; + localBoneSets[key] = set = new HashSet(); foreach (var v in list) set.Add(v); } + if (localBoneSets.Count == 0) + { + var removedCount = papOnly.Count; + papOnly.Clear(); + return removedCount; + } + int removed = 0; - foreach (var hash in papOnly.Keys.Select(k => k.Hash).Where(h => !string.IsNullOrEmpty(h)).Distinct(StringComparer.OrdinalIgnoreCase).ToList()) + var groups = papOnly + .Where(kvp => !string.IsNullOrEmpty(kvp.Key.Hash)) + .GroupBy(kvp => kvp.Key.Hash!, StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (var grp in groups) { token.ThrowIfCancellationRequested(); - var papIndices = await _dalamudUtil.RunOnFrameworkThread( - () => _modelAnalyzer.GetBoneIndicesFromPap(hash!)) - .ConfigureAwait(false); + var hash = grp.Key; + + var papPath = grp.Select(x => x.Value) + .FirstOrDefault(p => !string.IsNullOrEmpty(p) && File.Exists(p)); + + if (string.IsNullOrEmpty(papPath)) + continue; + + var havokBytes = await Task.Run(() => XivDataAnalyzer.ReadHavokBytesFromPap(papPath), token) + .ConfigureAwait(false); + + if (havokBytes is not { Length: > 8 }) + continue; + + Dictionary>? papIndices; + + await _papParseLimiter.WaitAsync(token).ConfigureAwait(false); + try + { + papIndices = await _dalamudUtil.RunOnFrameworkThread( + () => _modelAnalyzer.ParseHavokBytesOnFrameworkThread(havokBytes, hash, persistToConfig: false)) + .ConfigureAwait(false); + } + finally + { + _papParseLimiter.Release(); + } if (papIndices == null || papIndices.Count == 0) continue; - if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105)) + if (papIndices.All(k => k.Value == null || k.Value.Count == 0 || k.Value.Max() <= 105)) continue; - if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out var reason)) + if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allowNeighborIndex, out var reason)) continue; - var keysToRemove = papOnly.Keys.Where(k => string.Equals(k.Hash, hash, StringComparison.OrdinalIgnoreCase)).ToList(); + var keysToRemove = grp.Select(x => x.Key).ToList(); foreach (var k in keysToRemove) papOnly.Remove(k); removed += keysToRemove.Count; - if (_blockedPapHashes.TryAdd(hash!, 0)) - Logger.LogWarning("Blocked remote object PAP (hash {hash}) for {handler}: {reason}", hash, GetLogIdentifier(), reason); + if (_blockedPapHashes.TryAdd(hash, 0)) + Logger.LogWarning("Blocked remote object PAP {papPath} (hash {hash}) for {handler}: {reason}", + papPath, hash, GetLogIdentifier(), reason); if (charaData.FileReplacements.TryGetValue(ObjectKind.Player, out var list)) { - list.RemoveAll(r => string.Equals(r.Hash, hash, StringComparison.OrdinalIgnoreCase) - && r.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase))); + list.RemoveAll(r => + string.Equals(r.Hash, hash, StringComparison.OrdinalIgnoreCase) && + r.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase))); } } @@ -2984,6 +3024,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return removed; } + private async Task ApplyCustomizeAsync(nint address, string customizeData, ObjectKind kind) { _customizeIds[kind] = await _ipcManager.CustomizePlus.SetBodyScaleAsync(address, customizeData).ConfigureAwait(false); diff --git a/LightlessSync/Services/XivDataAnalyzer.cs b/LightlessSync/Services/XivDataAnalyzer.cs index 0959c80..c15ac5c 100644 --- a/LightlessSync/Services/XivDataAnalyzer.cs +++ b/LightlessSync/Services/XivDataAnalyzer.cs @@ -132,127 +132,54 @@ public sealed partial class XivDataAnalyzer return (output.Count != 0 && output.Values.All(v => v.Count > 0)) ? output : null; } - public unsafe Dictionary>? GetBoneIndicesFromPap(string hash, bool persistToConfig = true) + public static byte[]? ReadHavokBytesFromPap(string papPath) { - 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 || string.IsNullOrEmpty(cacheEntity.ResolvedFilepath) || !File.Exists(cacheEntity.ResolvedFilepath)) - return null; - - using var fs = File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var fs = File.Open(papPath, FileMode.Open, FileAccess.Read, FileShare.Read); using var reader = new BinaryReader(fs); - // PAP header (mostly from vfxeditor) - try - { - _ = reader.ReadInt32(); // ignore - _ = reader.ReadInt32(); // ignore - var numAnimations = reader.ReadInt16(); // num animations - var modelId = reader.ReadInt16(); // modelid + _ = reader.ReadInt32(); + _ = reader.ReadInt32(); + _ = reader.ReadInt16(); + _ = reader.ReadInt16(); - if (numAnimations < 0 || numAnimations > 1000) - { - _logger.LogWarning("PAP file {hash} has invalid animation count {count}, skipping", hash, numAnimations); - return null; - } + var type = reader.ReadByte(); + if (type != 0) return null; - var type = reader.ReadByte(); // type - if (type != 0) - return null; // not human + _ = reader.ReadByte(); + _ = reader.ReadInt32(); - _ = reader.ReadByte(); // variant - _ = reader.ReadInt32(); // ignore + var havokPosition = reader.ReadInt32(); + var footerPosition = reader.ReadInt32(); - var havokPosition = reader.ReadInt32(); - var footerPosition = reader.ReadInt32(); - - if (havokPosition <= 0 || footerPosition <= havokPosition || - footerPosition > fs.Length || havokPosition >= fs.Length) - { - _logger.LogWarning("PAP file {hash} has invalid offsets (havok={havok}, footer={footer}, length={length})", - hash, havokPosition, footerPosition, fs.Length); - return null; - } - - var havokDataSizeLong = (long)footerPosition - havokPosition; - if (havokDataSizeLong <= 8 || havokDataSizeLong > int.MaxValue) - { - _logger.LogWarning("PAP file {hash} has invalid Havok data size {size}", hash, havokDataSizeLong); - return null; - } - - var havokDataSize = (int)havokDataSizeLong; - - reader.BaseStream.Position = havokPosition; - var havokData = reader.ReadBytes(havokDataSize); - if (havokData.Length != havokDataSize) + if (havokPosition <= 0 || footerPosition <= havokPosition || footerPosition > fs.Length) return null; + var sizeLong = (long)footerPosition - havokPosition; + if (sizeLong <= 8 || sizeLong > int.MaxValue) + return null; + + var size = (int)sizeLong; + + fs.Position = havokPosition; + var bytes = reader.ReadBytes(size); + return bytes.Length > 8 ? bytes : null; + } + + public unsafe Dictionary>? ParseHavokBytesOnFrameworkThread( + byte[] havokData, + string hash, + bool persistToConfig) + { var tempSets = new Dictionary>(StringComparer.OrdinalIgnoreCase); - var tempHavokDataPath = Path.Combine(Path.GetTempPath(), $"lightless_{Guid.NewGuid():N}.hkx"); - var tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath); + var tempHkxPath = Path.Combine(Path.GetTempPath(), $"lightless_{Guid.NewGuid():N}.hkx"); + IntPtr pathAnsi = IntPtr.Zero; - try - { - var tempDir = Path.GetDirectoryName(tempHavokDataPath); - if (!Directory.Exists(tempDir)) - { - _logger.LogWarning("Temp directory {dir} doesn't exist", tempDir); - return null; - } + try + { + File.WriteAllBytes(tempHkxPath, havokData); - // Write the file with explicit error handling - try - { - File.WriteAllBytes(tempHavokDataPath, havokData); - } - catch (Exception writeEx) - { - _logger.LogError(writeEx, "Failed to write temporary Havok file to {path}", tempHavokDataPath); - return null; - } - - if (!File.Exists(tempHavokDataPath)) - { - _logger.LogWarning("Temporary havok file was not created at {path}", tempHavokDataPath); - return null; - } - - var writtenFileInfo = new FileInfo(tempHavokDataPath); - if (writtenFileInfo.Length != havokData.Length) - { - _logger.LogWarning("Written temp file size mismatch: expected {expected}, got {actual}", - havokData.Length, writtenFileInfo.Length); - try { File.Delete(tempHavokDataPath); } catch { } - return null; - } - - Thread.Sleep(10); // stabilize file system - - try - { - using var testStream = File.OpenRead(tempHavokDataPath); - if (testStream.Length != havokData.Length) - { - _logger.LogWarning("File verification failed: length mismatch after write"); - try { File.Delete(tempHavokDataPath); } catch { } - return null; - } - } - catch (Exception readEx) - { - _logger.LogError(readEx, "Cannot read back temporary file at {path}", tempHavokDataPath); - try { File.Delete(tempHavokDataPath); } catch { } - return null; - } - - var pathBytes = System.Text.Encoding.ASCII.GetBytes(tempHavokDataPath + "\0"); + pathAnsi = Marshal.StringToHGlobalAnsi(tempHkxPath); hkSerializeUtil.LoadOptions loadOptions = default; loadOptions.TypeInfoRegistry = hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry(); @@ -262,143 +189,77 @@ public sealed partial class XivDataAnalyzer Storage = (int)hkSerializeUtil.LoadOptionBits.Default }; - fixed (byte* pPath = pathBytes) - { - var resource = hkSerializeUtil.LoadFromFile(pPath, errorResult: null, &loadOptions); - if (resource == null) - return null; + hkSerializeUtil.LoadOptions* pOpts = &loadOptions; - var rootLevelName = @"hkRootLevelContainer"u8; - fixed (byte* n1 = rootLevelName) - { - var container = (hkRootLevelContainer*)resource->GetContentsPointer(n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry()); - if (container == null) - return null; + var resource = hkSerializeUtil.LoadFromFile((byte*)pathAnsi, errorResult: null, pOpts); + if (resource == null) + 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; + if (animContainer == null) return null; - for (int i = 0; i < animContainer->Bindings.Length; i++) + 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; + if (boneTransform.Length <= 0) continue; + + if (!tempSets.TryGetValue(skeletonKey, out var set)) + tempSets[skeletonKey] = set = []; + + for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++) { - var binding = animContainer->Bindings[i].ptr; - if (binding == null) - continue; - - var rawSkel = binding->OriginalSkeletonName.String; - var skeletonKey = CanonicalizeSkeletonKey(rawSkel); - if (string.IsNullOrEmpty(skeletonKey) || string.Equals(skeletonKey, "skeleton", StringComparison.OrdinalIgnoreCase)) - skeletonKey = "__any__"; - - var boneTransform = binding->TransformTrackToBoneIndices; - if (boneTransform.Length <= 0) - continue; - - if (!tempSets.TryGetValue(skeletonKey, out var set)) - { - set = []; - tempSets[skeletonKey] = set; - } - - for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++) - { - var v = boneTransform[boneIdx]; - if (v < 0) continue; - set.Add((ushort)v); - } + var v = boneTransform[boneIdx]; + if (v < 0) continue; + set.Add((ushort)v); } } } } } - catch (Exception ex) - { - _logger.LogWarning(ex, "Could not load havok file in {path}", tempHavokDataPath); - return null; - } finally { - if (tempHavokDataPathAnsi != IntPtr.Zero) - Marshal.FreeHGlobal(tempHavokDataPathAnsi); + if (pathAnsi != IntPtr.Zero) + Marshal.FreeHGlobal(pathAnsi); - int retryCount = 3; - while (retryCount > 0 && File.Exists(tempHavokDataPath)) - { - try - { - File.Delete(tempHavokDataPath); - break; - } - catch (IOException ex) - { - retryCount--; - if (retryCount == 0) - { - _logger.LogDebug(ex, "Failed to delete temporary havok file after retries: {path}", tempHavokDataPath); - } - else - { - Thread.Sleep(50); - } - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Unexpected error deleting temporary havok file: {path}", tempHavokDataPath); - break; - } - } - } - - if (tempSets.Count == 0) - { - _logger.LogDebug("No bone sets found in PAP file (hash={hash})", hash); - return null; - } - - var output = new Dictionary>(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; - - if (persistToConfig) - _configService.Save(); - - return output; + try { if (File.Exists(tempHkxPath)) File.Delete(tempHkxPath); } + catch { /* ignore */ } } - catch (Exception ex) + + if (tempSets.Count == 0) return null; + + var output = new Dictionary>(tempSets.Count, StringComparer.OrdinalIgnoreCase); + foreach (var (key, set) in tempSets) { - _logger.LogError(ex, "Outer exception reading PAP file (hash={hash})", hash); - return null; + if (set.Count == 0) continue; + var list = set.ToList(); + list.Sort(); + output[key] = list; } - } - private static bool IsValidPointer(IntPtr ptr) - { - if (ptr == IntPtr.Zero) - return false; + if (output.Count == 0) return null; - try - { - _ = Marshal.ReadByte(ptr); - return true; - } - catch - { - return false; - } + _configService.Current.BonesDictionary[hash] = output; + if (persistToConfig) _configService.Save(); + + return output; } public static string CanonicalizeSkeletonKey(string? raw) -- 2.49.1 From 19966f3828d3a4ec365f8b5bb406f64d8cb253ef Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 6 Jan 2026 15:06:21 +0100 Subject: [PATCH 60/87] Default to unsafe --- .../LightlessConfiguration/Configurations/LightlessConfig.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index 8f1a3de..9e92b63 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -159,8 +159,8 @@ public class LightlessConfig : ILightlessConfiguration public string LastSeenVersion { get; set; } = string.Empty; public bool EnableParticleEffects { get; set; } = true; public HashSet OrphanableTempCollections { get; set; } = []; - public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Safe; - public bool AnimationAllowOneBasedShift { get; set; } = true; + public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Unsafe; + public bool AnimationAllowOneBasedShift { get; set; } = false; public bool AnimationAllowNeighborIndexTolerance { get; set; } = false; } -- 2.49.1 From 058ba504cb073bab51690e602b84c14bc9b97a86 Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 6 Jan 2026 15:10:15 +0100 Subject: [PATCH 61/87] Updated text of options --- LightlessSync/UI/SettingsUi.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 96a300b..207455e 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -3249,16 +3249,16 @@ public class SettingsUi : WindowMediatorSubscriberBase var labels = new[] { - "Unsafe", - "Safe (Race)", - "Safest (Race + Bones)", + "Unsafe (Off)", + "Safe (Race Check)", + "Safest (Race + Bones Check)", }; var tooltips = new[] { - "No validation. Fastest, but may allow incompatible animations (riskier).", - "Validates skeleton race + modded skeleton check (recommended).", - "Requires matching skeleton race + bone compatibility (strictest).", + "No validation. Fastest, but may allow incompatible animations.", + "Validates skeleton race + modded skeleton check. Will be safer to use but will block some animations", + "Requires matching skeleton race + bone compatibility. Will block alot, not recommended.", }; -- 2.49.1 From 05f7d256d7224a26a236ba061c7d5cf7083cd2f3 Mon Sep 17 00:00:00 2001 From: choco Date: Wed, 7 Jan 2026 00:54:50 +0100 Subject: [PATCH 62/87] syncshell join modal to overlay, which doesnt prevent game interaction --- LightlessSync/UI/LightFinderUI.cs | 42 +++++++++++-------------------- 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/LightlessSync/UI/LightFinderUI.cs b/LightlessSync/UI/LightFinderUI.cs index cad3d0b..e41d17d 100644 --- a/LightlessSync/UI/LightFinderUI.cs +++ b/LightlessSync/UI/LightFinderUI.cs @@ -759,29 +759,22 @@ public class LightFinderUI : WindowMediatorSubscriberBase var scale = ImGuiHelpers.GlobalScale; - // if not already open - if (!ImGui.IsPopupOpen("JoinSyncshellModal")) - ImGui.OpenPopup("JoinSyncshellModal"); - - Vector2 windowPos = ImGui.GetWindowPos(); Vector2 windowSize = ImGui.GetWindowSize(); float modalWidth = Math.Min(420f * scale, windowSize.X - 40f * scale); float modalHeight = 295f * scale; - ImGui.SetNextWindowPos(new Vector2( - windowPos.X + (windowSize.X - modalWidth) * 0.5f, - windowPos.Y + (windowSize.Y - modalHeight) * 0.5f - ), ImGuiCond.Always); - ImGui.SetNextWindowSize(new Vector2(modalWidth, modalHeight)); - ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * ImGuiHelpers.GlobalScale); - ImGui.PushStyleColor(ImGuiCol.Border, Vector4.Zero); + Vector2 childPos = new Vector2( + (windowSize.X - modalWidth) * 0.5f, + (windowSize.Y - modalHeight) * 0.5f + ); + ImGui.SetCursorPos(childPos); - using ImRaii.Color modalBorder = ImRaii.PushColor(ImGuiCol.Border, UIColors.Get("LightlessPurple").WithAlpha(0.5f)); - using ImRaii.Style rounding = ImRaii.PushStyle(ImGuiStyleVar.WindowRounding, 8f * scale); - using ImRaii.Style borderSize = ImRaii.PushStyle(ImGuiStyleVar.WindowBorderSize, 2f * scale); - using ImRaii.Style padding = ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(16f * scale, 16f * scale)); + using var modalBorder = ImRaii.PushColor(ImGuiCol.Border, UIColors.Get("LightlessPurple").WithAlpha(0.5f)); + using var childBg = ImRaii.PushColor(ImGuiCol.ChildBg, ImGui.GetStyle().Colors[(int)ImGuiCol.WindowBg]); + using var rounding = ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 8f * scale); + using var borderSize = ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, 2f * scale); + using var padding = ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(16f * scale, 16f * scale)); - ImGuiWindowFlags flags = ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoScrollbar; - if (ImGui.BeginPopupModal("JoinSyncshellModal", ref _joinModalOpen, flags)) + if (ImGui.BeginChild("JoinSyncshellOverlay", new Vector2(modalWidth, modalHeight), true, ImGuiWindowFlags.NoScrollbar)) { float contentWidth = ImGui.GetContentRegionAvail().X; @@ -843,7 +836,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase _joinDto = null; _joinInfo = null; - ImGui.CloseCurrentPopup(); + } } @@ -858,20 +851,13 @@ public class LightFinderUI : WindowMediatorSubscriberBase { _joinDto = null; _joinInfo = null; - ImGui.CloseCurrentPopup(); + } } } - // Handle modal close via the bool ref - if (!_joinModalOpen) - { - _joinDto = null; - _joinInfo = null; - } - - ImGui.EndPopup(); } + ImGui.EndChild(); } private void DrawPermissionToggleRow(string label, FontAwesomeIcon icon, bool suggested, bool current, Action apply, float contentWidth) -- 2.49.1 From 42d6a19db196be9b6d8a6db03925c787a7274c51 Mon Sep 17 00:00:00 2001 From: choco Date: Wed, 7 Jan 2026 01:08:28 +0100 Subject: [PATCH 63/87] syncshell list cleanup --- LightlessSync/UI/LightFinderUI.cs | 32 ++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/LightlessSync/UI/LightFinderUI.cs b/LightlessSync/UI/LightFinderUI.cs index e41d17d..09ba646 100644 --- a/LightlessSync/UI/LightFinderUI.cs +++ b/LightlessSync/UI/LightFinderUI.cs @@ -497,7 +497,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase var cardData = new List<(GroupJoinDto Shell, string BroadcasterName, bool IsOwnBroadcast)>(); - foreach (var shell in _nearbySyncshells) + foreach (var shell in _nearbySyncshells.ToArray()) { if (shell?.Group == null || string.IsNullOrEmpty(shell.Group.GID)) continue; @@ -1566,11 +1566,20 @@ public class LightFinderUI : WindowMediatorSubscriberBase if (previousGid != null) { - var newIndex = _nearbySyncshells.FindIndex(s => string.Equals(s.Group.GID, previousGid, StringComparison.Ordinal)); - if (newIndex >= 0) + try { - _selectedNearbyIndex = newIndex; - return; + var nearbySyncshellsSnapshot = _nearbySyncshells.ToArray(); + var newIndex = Array.FindIndex(nearbySyncshellsSnapshot, + s => string.Equals(s.Group.GID, previousGid, StringComparison.Ordinal)); + if (newIndex >= 0) + { + _selectedNearbyIndex = newIndex; + return; + } + } + catch + { + ClearSelection(); } } @@ -1612,9 +1621,18 @@ public class LightFinderUI : WindowMediatorSubscriberBase private string? GetSelectedGid() { - if (_selectedNearbyIndex < 0 || _selectedNearbyIndex >= _nearbySyncshells.Count) + try + { + var index = _selectedNearbyIndex; + var list = _nearbySyncshells.ToArray(); + if (index < 0 || index >= list.Length) + return null; + return list[index].Group.GID; + } + catch + { return null; - return _nearbySyncshells[_selectedNearbyIndex].Group.GID; + } } #endregion -- 2.49.1 From 95d286f99090057b008514bf30b09d3e15907749 Mon Sep 17 00:00:00 2001 From: celine Date: Thu, 8 Jan 2026 20:35:15 +0000 Subject: [PATCH 64/87] Yeet --- .../workflows/lightless-tag-and-release.yml | 43 +++++-------------- 1 file changed, 11 insertions(+), 32 deletions(-) diff --git a/.gitea/workflows/lightless-tag-and-release.yml b/.gitea/workflows/lightless-tag-and-release.yml index a91d953..35ddcc5 100644 --- a/.gitea/workflows/lightless-tag-and-release.yml +++ b/.gitea/workflows/lightless-tag-and-release.yml @@ -9,7 +9,8 @@ env: DOTNET_VERSION: | 10.x.x 9.x.x - + DOTNET_CLI_TELEMETRY_OPTOUT: true + jobs: tag-and-release: runs-on: ubuntu-22.04 @@ -32,16 +33,14 @@ jobs: - name: Download Dalamud run: | - cd / - mkdir -p root/.xlcore/dalamud/Hooks/dev + mkdir -p ~/.xlcore/dalamud/Hooks/dev curl -O https://goatcorp.github.io/dalamud-distrib/stg/latest.zip - unzip latest.zip -d /root/.xlcore/dalamud/Hooks/dev + unzip latest.zip -d ~/.xlcore/dalamud/Hooks/dev - name: Lets Build Lightless! run: | - dotnet restore - dotnet build --configuration Release --no-restore - dotnet publish --configuration Release --no-build + dotnet publish --configuration Release + mv LightlessSync/bin/x64/Release/LightlessSync/latest.zip LightlessClient.zip - name: Get version id: package_version @@ -53,19 +52,6 @@ jobs: run: | echo "Version: ${{ steps.package_version.outputs.version }}" - - name: Prepare Lightless Client - run: | - PUBLISH_PATH="/workspace/Lightless-Sync/LightlessClient/LightlessSync/bin/x64/Release/publish/" - if [ -d "$PUBLISH_PATH" ]; then - rm -rf "$PUBLISH_PATH" - echo "Removed $PUBLISH_PATH" - else - echo "$PUBLISH_PATH does not exist, nothing to remove." - fi - - mkdir -p output - (cd /workspace/Lightless-Sync/LightlessClient/LightlessSync/bin/x64/Release/ && zip -r $OLDPWD/output/LightlessClient.zip *) - - name: Create Git tag if not exists (master) if: github.ref == 'refs/heads/master' run: | @@ -162,14 +148,7 @@ jobs: echo "release_id=$release_id" echo "release_id=$release_id" >> $GITHUB_OUTPUT || echo "::set-output name=release_id::$release_id" echo "RELEASE_ID=$release_id" >> $GITHUB_ENV - - - name: Check asset exists - run: | - if [ ! -f output/LightlessClient.zip ]; then - echo "output/LightlessClient.zip does not exist!" - exit 1 - fi - + - name: Upload Assets to release env: RELEASE_ID: ${{ env.RELEASE_ID }} @@ -177,7 +156,7 @@ jobs: echo "Uploading to release ID: $RELEASE_ID" curl --fail-with-body -s -X POST \ -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \ - -F "attachment=@output/LightlessClient.zip" \ + -F "attachment=LightlessClient.zip" \ "https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases/$RELEASE_ID/assets" - name: Clone plugin hosting repo @@ -186,7 +165,7 @@ jobs: cd LightlessSyncRepo git clone https://git.lightless-sync.org/${{ gitea.repository_owner }}/LightlessSync.git env: - GIT_TERMINAL_PROMPT: 0 + GIT_TERMINAL_PROMPT: 0 - name: Update plogonmaster.json with version (master) if: github.ref == 'refs/heads/master' @@ -282,8 +261,8 @@ jobs: - name: Commit and push to LightlessSync run: | cd LightlessSyncRepo/LightlessSync - git config user.name "github-actions" - git config user.email "github-actions@github.com" + git config user.name "Gitea-Automation" + git config user.email "aaa@aaaaaaa.aaa" git add . git diff-index --quiet HEAD || git commit -m "Update ${{ env.PLUGIN_NAME }} to ${{ steps.package_version.outputs.version }}" git push https://x-access-token:${{ secrets.AUTOMATION_TOKEN }}@git.lightless-sync.org/${{ gitea.repository_owner }}/LightlessSync.git HEAD:main -- 2.49.1 From 7f33b6a4ce1c19f240f31a67d48ac3e0a9a33fa7 Mon Sep 17 00:00:00 2001 From: celine Date: Fri, 9 Jan 2026 06:11:08 +0000 Subject: [PATCH 65/87] Fixed missing symbol "@" was missing for the LightlessClient.zip --- .gitea/workflows/lightless-tag-and-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/lightless-tag-and-release.yml b/.gitea/workflows/lightless-tag-and-release.yml index 35ddcc5..bc04f76 100644 --- a/.gitea/workflows/lightless-tag-and-release.yml +++ b/.gitea/workflows/lightless-tag-and-release.yml @@ -156,7 +156,7 @@ jobs: echo "Uploading to release ID: $RELEASE_ID" curl --fail-with-body -s -X POST \ -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \ - -F "attachment=LightlessClient.zip" \ + -F "attachment=@LightlessClient.zip" \ "https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases/$RELEASE_ID/assets" - name: Clone plugin hosting repo -- 2.49.1 From 4502cadaeb18a8cc59351fd6d92ce5efee0da569 Mon Sep 17 00:00:00 2001 From: Tsubasahane Date: Mon, 12 Jan 2026 13:55:09 +0800 Subject: [PATCH 66/87] Fix lumina offset for WorldSheet --- LightlessSync/Services/DalamudUtilService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 5da96bb..6f0869d 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -93,7 +93,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber { return gameData.GetExcelSheet(clientLanguage)! .Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0]) - || w is { RowId: > 1000, Region: 101 or 201 })) + || w is { RowId: > 1000, UserType: 101 or 201 })) .ToDictionary(w => (ushort)w.RowId, w => w.Name.ToString()); }); JobData = new(() => -- 2.49.1 From 96123d00a29fd065424ab3409171351a92d9663d Mon Sep 17 00:00:00 2001 From: azyges Date: Fri, 16 Jan 2026 11:00:58 +0900 Subject: [PATCH 67/87] sigma update --- .../FileCache/CompactorInterfaces.cs | 18 + .../FileCache/FileCompactor.cs | 88 +- LightlessCompactor/LightlessCompactor.csproj | 15 + .../Compactor/BatchFileFragService.cs | 0 .../Utils/FileSystemHelper.cs | 0 .../LightlessCompactorWorker.csproj | 19 + LightlessCompactorWorker/Program.cs | 270 +++ LightlessSync.sln | 28 + .../FileCache/ExternalCompactionExecutor.cs | 241 +++ LightlessSync/FileCache/FileCacheManager.cs | 34 + .../FileCache/PluginCompactorContext.cs | 20 + .../FileCache/TransientResourceManager.cs | 77 +- .../Interop/Ipc/IpcCallerPenumbra.cs | 10 +- .../Interop/Ipc/Penumbra/PenumbraResource.cs | 23 +- .../Interop/Ipc/Penumbra/PenumbraTexture.cs | 4 +- .../Configurations/ChatConfig.cs | 6 + .../Configurations/LightlessConfig.cs | 2 + .../Configurations/PlayerPerformanceConfig.cs | 5 +- LightlessSync/LightlessSync.csproj | 10 + .../Factories/FileDownloadManagerFactory.cs | 8 +- .../PlayerData/Factories/PlayerDataFactory.cs | 56 +- .../PlayerData/Pairs/IPairHandlerAdapter.cs | 79 +- .../PlayerData/Pairs/PairHandlerAdapter.cs | 165 +- LightlessSync/PlayerData/Pairs/PairLedger.cs | 15 +- LightlessSync/Plugin.cs | 7 +- .../ActorTracking/ActorObjectService.cs | 68 +- .../Services/Chat/ZoneChatService.cs | 477 +++++- LightlessSync/Services/DalamudUtilService.cs | 98 +- LightlessSync/Services/Mediator/Messages.cs | 6 + .../Services/ModelDecimation/MdlDecimator.cs | 598 ++++++- .../ModelDecimation/ModelDecimationService.cs | 27 +- .../TextureCompressionService.cs | 75 +- .../TextureDownscaleService.cs | 217 ++- LightlessSync/Services/UiService.cs | 74 +- .../FastQuadricMeshSimplification.cs | 78 + .../ThirdParty/MeshDecimator/Mesh.cs | 51 + LightlessSync/UI/CompactUI.cs | 128 +- LightlessSync/UI/Components/DrawFolderBase.cs | 7 +- .../UI/Components/DrawGroupedGroupFolder.cs | 48 +- LightlessSync/UI/Components/DrawUserPair.cs | 58 +- .../Components/OptimizationSettingsPanel.cs | 930 +++++++++++ .../UI/Components/OptimizationSummaryCard.cs | 789 +++++++++ LightlessSync/UI/DownloadUi.cs | 11 +- LightlessSync/UI/SettingsUi.cs | 656 +++----- LightlessSync/UI/Style/MainStyle.cs | 8 +- LightlessSync/UI/Style/Selune.cs | 22 +- LightlessSync/UI/ZoneChatUi.cs | 1455 +++++++++++++++-- LightlessSync/Utils/TaskRegistry.cs | 81 + .../WebAPI/Files/FileDownloadDeduplicator.cs | 48 + .../WebAPI/Files/FileDownloadManager.cs | 806 +++++++-- LightlessSync/packages.lock.json | 6 + 51 files changed, 6640 insertions(+), 1382 deletions(-) create mode 100644 LightlessCompactor/FileCache/CompactorInterfaces.cs rename {LightlessSync => LightlessCompactor}/FileCache/FileCompactor.cs (94%) create mode 100644 LightlessCompactor/LightlessCompactor.csproj rename {LightlessSync => LightlessCompactor}/Services/Compactor/BatchFileFragService.cs (100%) rename {LightlessSync => LightlessCompactor}/Utils/FileSystemHelper.cs (100%) create mode 100644 LightlessCompactorWorker/LightlessCompactorWorker.csproj create mode 100644 LightlessCompactorWorker/Program.cs create mode 100644 LightlessSync/FileCache/ExternalCompactionExecutor.cs create mode 100644 LightlessSync/FileCache/PluginCompactorContext.cs create mode 100644 LightlessSync/UI/Components/OptimizationSettingsPanel.cs create mode 100644 LightlessSync/UI/Components/OptimizationSummaryCard.cs create mode 100644 LightlessSync/Utils/TaskRegistry.cs create mode 100644 LightlessSync/WebAPI/Files/FileDownloadDeduplicator.cs diff --git a/LightlessCompactor/FileCache/CompactorInterfaces.cs b/LightlessCompactor/FileCache/CompactorInterfaces.cs new file mode 100644 index 0000000..59fc255 --- /dev/null +++ b/LightlessCompactor/FileCache/CompactorInterfaces.cs @@ -0,0 +1,18 @@ +namespace LightlessSync.FileCache; + +public interface ICompactorContext +{ + bool UseCompactor { get; } + string CacheFolder { get; } + bool IsWine { get; } +} + +public interface ICompactionExecutor +{ + bool TryCompact(string filePath); +} + +public sealed class NoopCompactionExecutor : ICompactionExecutor +{ + public bool TryCompact(string filePath) => false; +} diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessCompactor/FileCache/FileCompactor.cs similarity index 94% rename from LightlessSync/FileCache/FileCompactor.cs rename to LightlessCompactor/FileCache/FileCompactor.cs index 771f558..cc3b46c 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessCompactor/FileCache/FileCompactor.cs @@ -1,6 +1,4 @@ -using LightlessSync.LightlessConfiguration; -using LightlessSync.Services; -using LightlessSync.Services.Compactor; +using LightlessSync.Services.Compactor; using Microsoft.Extensions.Logging; using Microsoft.Win32.SafeHandles; using System.Collections.Concurrent; @@ -20,8 +18,8 @@ public sealed partial class FileCompactor : IDisposable private readonly ConcurrentDictionary _pendingCompactions; private readonly ILogger _logger; - private readonly LightlessConfigService _lightlessConfigService; - private readonly DalamudUtilService _dalamudUtilService; + private readonly ICompactorContext _context; + private readonly ICompactionExecutor _compactionExecutor; private readonly Channel _compactionQueue; private readonly CancellationTokenSource _compactionCts = new(); @@ -59,12 +57,12 @@ public sealed partial class FileCompactor : IDisposable XPRESS16K = 3 } - public FileCompactor(ILogger logger, LightlessConfigService lightlessConfigService, DalamudUtilService dalamudUtilService) + public FileCompactor(ILogger logger, ICompactorContext context, ICompactionExecutor compactionExecutor) { _pendingCompactions = new(StringComparer.OrdinalIgnoreCase); - _logger = logger; - _lightlessConfigService = lightlessConfigService; - _dalamudUtilService = dalamudUtilService; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _context = context ?? throw new ArgumentNullException(nameof(context)); + _compactionExecutor = compactionExecutor ?? throw new ArgumentNullException(nameof(compactionExecutor)); _isWindows = OperatingSystem.IsWindows(); _compactionQueue = Channel.CreateUnbounded(new UnboundedChannelOptions @@ -94,7 +92,7 @@ public sealed partial class FileCompactor : IDisposable //Uses an batching service for the filefrag command on Linux _fragBatch = new BatchFilefragService( - useShell: _dalamudUtilService.IsWine, + useShell: _context.IsWine, log: _logger, batchSize: 64, flushMs: 25, @@ -118,7 +116,7 @@ public sealed partial class FileCompactor : IDisposable try { - var folder = _lightlessConfigService.Current.CacheFolder; + var folder = _context.CacheFolder; if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) { if (_logger.IsEnabled(LogLevel.Warning)) @@ -127,7 +125,7 @@ public sealed partial class FileCompactor : IDisposable return; } - var files = Directory.EnumerateFiles(folder).ToArray(); + var files = Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories).ToArray(); var total = files.Length; Progress = $"0/{total}"; if (total == 0) return; @@ -155,7 +153,7 @@ public sealed partial class FileCompactor : IDisposable { if (compress) { - if (_lightlessConfigService.Current.UseCompactor) + if (_context.UseCompactor) CompactFile(file, workerId); } else @@ -221,19 +219,52 @@ public sealed partial class FileCompactor : IDisposable await File.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false); - if (_lightlessConfigService.Current.UseCompactor) + if (_context.UseCompactor) EnqueueCompaction(filePath); } + /// + /// Notify the compactor that a file was written directly (streamed) so it can enqueue compaction. + /// + public void NotifyFileWritten(string filePath) + { + EnqueueCompaction(filePath); + } + + public bool TryCompactFile(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + return false; + + if (!_context.UseCompactor || !File.Exists(filePath)) + return false; + + try + { + CompactFile(filePath, workerId: -1); + return true; + } + catch (IOException ioEx) + { + _logger.LogDebug(ioEx, "File being read/written, skipping file: {file}", filePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error compacting file: {file}", filePath); + } + + return false; + } + /// /// Gets the File size for an BTRFS or NTFS file system for the given FileInfo /// /// Amount of blocks used in the disk public long GetFileSizeOnDisk(FileInfo fileInfo) { - var fsType = GetFilesystemType(fileInfo.FullName, _dalamudUtilService.IsWine); + var fsType = GetFilesystemType(fileInfo.FullName, _context.IsWine); - if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine) + if (fsType == FilesystemType.NTFS && !_context.IsWine) { (bool flowControl, long value) = GetFileSizeNTFS(fileInfo); if (!flowControl) @@ -290,7 +321,7 @@ public sealed partial class FileCompactor : IDisposable { try { - var blockSize = GetBlockSizeForPath(fileInfo.FullName, _logger, _dalamudUtilService.IsWine); + var blockSize = GetBlockSizeForPath(fileInfo.FullName, _logger, _context.IsWine); if (blockSize <= 0) throw new InvalidOperationException($"Invalid block size {blockSize} for {fileInfo.FullName}"); @@ -330,7 +361,7 @@ public sealed partial class FileCompactor : IDisposable return; } - var fsType = GetFilesystemType(filePath, _dalamudUtilService.IsWine); + var fsType = GetFilesystemType(filePath, _context.IsWine); var oldSize = fi.Length; int blockSize = (int)(GetFileSizeOnDisk(fi) / 512); @@ -346,7 +377,7 @@ public sealed partial class FileCompactor : IDisposable return; } - if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine) + if (fsType == FilesystemType.NTFS && !_context.IsWine) { if (!IsWOFCompactedFile(filePath)) { @@ -402,9 +433,9 @@ public sealed partial class FileCompactor : IDisposable private void DecompressFile(string filePath, int workerId) { _logger.LogDebug("[W{worker}] Decompress request: {file}", workerId, filePath); - var fsType = GetFilesystemType(filePath, _dalamudUtilService.IsWine); + var fsType = GetFilesystemType(filePath, _context.IsWine); - if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine) + if (fsType == FilesystemType.NTFS && !_context.IsWine) { try { @@ -448,7 +479,7 @@ public sealed partial class FileCompactor : IDisposable { try { - bool isWine = _dalamudUtilService?.IsWine ?? false; + bool isWine = _context.IsWine; string linuxPath = isWine ? ToLinuxPathIfWine(path, isWine) : path; var opts = GetMountOptionsForPath(linuxPath); @@ -961,7 +992,7 @@ public sealed partial class FileCompactor : IDisposable if (finished != bothTasks) return KillProcess(proc, outTask, errTask, token); - bool isWine = _dalamudUtilService?.IsWine ?? false; + bool isWine = _context.IsWine; if (!isWine) { try { proc.WaitForExit(); } catch { /* ignore quirks */ } @@ -1005,7 +1036,7 @@ public sealed partial class FileCompactor : IDisposable if (string.IsNullOrWhiteSpace(filePath)) return; - if (!_lightlessConfigService.Current.UseCompactor) + if (!_context.UseCompactor) return; if (!File.Exists(filePath)) @@ -1017,7 +1048,7 @@ public sealed partial class FileCompactor : IDisposable bool enqueued = false; try { - bool isWine = _dalamudUtilService?.IsWine ?? false; + bool isWine = _context.IsWine; var fsType = GetFilesystemType(filePath, isWine); // If under Wine, we should skip NTFS because its not Windows but might return NTFS. @@ -1070,8 +1101,11 @@ public sealed partial class FileCompactor : IDisposable try { - if (_lightlessConfigService.Current.UseCompactor && File.Exists(filePath)) - CompactFile(filePath, workerId); + if (_context.UseCompactor && File.Exists(filePath)) + { + if (!_compactionExecutor.TryCompact(filePath)) + CompactFile(filePath, workerId); + } } finally { diff --git a/LightlessCompactor/LightlessCompactor.csproj b/LightlessCompactor/LightlessCompactor.csproj new file mode 100644 index 0000000..419cd5c --- /dev/null +++ b/LightlessCompactor/LightlessCompactor.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + latest + enable + enable + true + + + + + + + diff --git a/LightlessSync/Services/Compactor/BatchFileFragService.cs b/LightlessCompactor/Services/Compactor/BatchFileFragService.cs similarity index 100% rename from LightlessSync/Services/Compactor/BatchFileFragService.cs rename to LightlessCompactor/Services/Compactor/BatchFileFragService.cs diff --git a/LightlessSync/Utils/FileSystemHelper.cs b/LightlessCompactor/Utils/FileSystemHelper.cs similarity index 100% rename from LightlessSync/Utils/FileSystemHelper.cs rename to LightlessCompactor/Utils/FileSystemHelper.cs diff --git a/LightlessCompactorWorker/LightlessCompactorWorker.csproj b/LightlessCompactorWorker/LightlessCompactorWorker.csproj new file mode 100644 index 0000000..e943619 --- /dev/null +++ b/LightlessCompactorWorker/LightlessCompactorWorker.csproj @@ -0,0 +1,19 @@ + + + + WinExe + net10.0 + latest + enable + enable + + + + + + + + + + + diff --git a/LightlessCompactorWorker/Program.cs b/LightlessCompactorWorker/Program.cs new file mode 100644 index 0000000..26f09d1 --- /dev/null +++ b/LightlessCompactorWorker/Program.cs @@ -0,0 +1,270 @@ +using LightlessSync.FileCache; +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.IO.Pipes; +using System.Text.Json; + +internal sealed class WorkerCompactorContext : ICompactorContext +{ + public WorkerCompactorContext(string cacheFolder, bool isWine) + { + CacheFolder = cacheFolder; + IsWine = isWine; + } + + public bool UseCompactor => true; + public string CacheFolder { get; } + public bool IsWine { get; } +} + +internal sealed class WorkerOptions +{ + public string? FilePath { get; init; } + public bool IsWine { get; init; } + public string CacheFolder { get; init; } = string.Empty; + public LogLevel LogLevel { get; init; } = LogLevel.Information; + public string PipeName { get; init; } = "LightlessCompactor"; + public int? ParentProcessId { get; init; } +} + +internal static class Program +{ + public static async Task Main(string[] args) + { + var options = ParseOptions(args, out var error); + if (options is null) + { + Console.Error.WriteLine(error ?? "Invalid arguments."); + Console.Error.WriteLine("Usage: LightlessCompactorWorker --file [--wine] [--cache-folder ] [--verbose]"); + Console.Error.WriteLine(" or: LightlessCompactorWorker --pipe [--wine] [--parent ] [--verbose]"); + return 2; + } + + TrySetLowPriority(); + + using var loggerFactory = LoggerFactory.Create(builder => + { + builder.SetMinimumLevel(options.LogLevel); + builder.AddSimpleConsole(o => + { + o.SingleLine = true; + o.TimestampFormat = "HH:mm:ss.fff "; + }); + }); + + var logger = loggerFactory.CreateLogger(); + var context = new WorkerCompactorContext(options.CacheFolder, options.IsWine); + + using var compactor = new FileCompactor(logger, context, new NoopCompactionExecutor()); + + if (!string.IsNullOrWhiteSpace(options.FilePath)) + { + var success = compactor.TryCompactFile(options.FilePath!); + return success ? 0 : 1; + } + + var serverLogger = loggerFactory.CreateLogger("CompactorWorker"); + return await RunServerAsync(compactor, options, serverLogger).ConfigureAwait(false); + } + + private static async Task RunServerAsync(FileCompactor compactor, WorkerOptions options, ILogger serverLogger) + { + using var cts = new CancellationTokenSource(); + var token = cts.Token; + + if (options.ParentProcessId.HasValue) + { + _ = Task.Run(() => MonitorParent(options.ParentProcessId.Value, cts)); + } + + serverLogger.LogInformation("Compactor worker listening on pipe {pipe}", options.PipeName); + + try + { + while (!token.IsCancellationRequested) + { + var server = new NamedPipeServerStream( + options.PipeName, + PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous); + + try + { + await server.WaitForConnectionAsync(token).ConfigureAwait(false); + } + catch + { + server.Dispose(); + throw; + } + + _ = Task.Run(() => HandleClientAsync(server, compactor, cts)); + } + } + catch (OperationCanceledException) + { + // shutdown requested + } + catch (Exception ex) + { + serverLogger.LogWarning(ex, "Compactor worker terminated unexpectedly."); + return 1; + } + + return 0; + } + + private static async Task HandleClientAsync(NamedPipeServerStream pipe, FileCompactor compactor, CancellationTokenSource shutdownCts) + { + await using var _ = pipe; + using var reader = new StreamReader(pipe); + using var writer = new StreamWriter(pipe) { AutoFlush = true }; + + var line = await reader.ReadLineAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(line)) + return; + + CompactorRequest? request = null; + try + { + request = JsonSerializer.Deserialize(line); + } + catch + { + // ignore + } + + CompactorResponse response; + if (request is null) + { + response = new CompactorResponse { Success = false, Error = "Invalid request." }; + } + else if (string.Equals(request.Type, "shutdown", StringComparison.OrdinalIgnoreCase)) + { + shutdownCts.Cancel(); + response = new CompactorResponse { Success = true }; + } + else if (string.Equals(request.Type, "compact", StringComparison.OrdinalIgnoreCase)) + { + var success = compactor.TryCompactFile(request.Path ?? string.Empty); + response = new CompactorResponse { Success = success }; + } + else + { + response = new CompactorResponse { Success = false, Error = "Unknown request type." }; + } + + await writer.WriteLineAsync(JsonSerializer.Serialize(response)).ConfigureAwait(false); + } + + private static void MonitorParent(int parentPid, CancellationTokenSource shutdownCts) + { + try + { + var parent = Process.GetProcessById(parentPid); + parent.WaitForExit(); + } + catch + { + // parent missing + } + finally + { + shutdownCts.Cancel(); + } + } + + private static WorkerOptions? ParseOptions(string[] args, out string? error) + { + string? filePath = null; + bool isWine = false; + string cacheFolder = string.Empty; + var logLevel = LogLevel.Information; + string pipeName = "LightlessCompactor"; + int? parentPid = null; + + for (int i = 0; i < args.Length; i++) + { + var arg = args[i]; + switch (arg) + { + case "--file": + if (i + 1 >= args.Length) + { + error = "Missing value for --file."; + return null; + } + filePath = args[++i]; + break; + case "--cache-folder": + if (i + 1 >= args.Length) + { + error = "Missing value for --cache-folder."; + return null; + } + cacheFolder = args[++i]; + break; + case "--pipe": + if (i + 1 >= args.Length) + { + error = "Missing value for --pipe."; + return null; + } + pipeName = args[++i]; + break; + case "--parent": + if (i + 1 >= args.Length || !int.TryParse(args[++i], out var pid)) + { + error = "Invalid value for --parent."; + return null; + } + parentPid = pid; + break; + case "--wine": + isWine = true; + break; + case "--verbose": + logLevel = LogLevel.Trace; + break; + } + } + + error = null; + return new WorkerOptions + { + FilePath = filePath, + IsWine = isWine, + CacheFolder = cacheFolder, + LogLevel = logLevel, + PipeName = pipeName, + ParentProcessId = parentPid + }; + } + + private static void TrySetLowPriority() + { + try + { + if (OperatingSystem.IsWindows()) + Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.BelowNormal; + } + catch + { + // ignore + } + } + + private sealed class CompactorRequest + { + public string Type { get; init; } = "compact"; + public string? Path { get; init; } + } + + private sealed class CompactorResponse + { + public bool Success { get; init; } + public string? Error { get; init; } + } +} diff --git a/LightlessSync.sln b/LightlessSync.sln index 55bddfd..f69eb4b 100644 --- a/LightlessSync.sln +++ b/LightlessSync.sln @@ -22,6 +22,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OtterGui", "OtterGui\OtterG EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Pictomancy", "ffxiv_pictomancy\Pictomancy\Pictomancy.csproj", "{825F17D8-2704-24F6-DF8B-2542AC92C765}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightlessCompactor", "LightlessCompactor\LightlessCompactor.csproj", "{01F31917-9F1E-426D-BDAE-17268CBF9523}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightlessCompactorWorker", "LightlessCompactorWorker\LightlessCompactorWorker.csproj", "{72BE3664-CD0E-4DA4-B040-91338A2798E0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -116,6 +120,30 @@ Global {825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x64.Build.0 = Release|x64 {825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x86.ActiveCfg = Release|x64 {825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x86.Build.0 = Release|x64 + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|Any CPU.Build.0 = Debug|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|x64.ActiveCfg = Debug|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|x64.Build.0 = Debug|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|x86.ActiveCfg = Debug|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|x86.Build.0 = Debug|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|Any CPU.ActiveCfg = Release|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|Any CPU.Build.0 = Release|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|x64.ActiveCfg = Release|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|x64.Build.0 = Release|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|x86.ActiveCfg = Release|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|x86.Build.0 = Release|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|x64.ActiveCfg = Debug|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|x64.Build.0 = Debug|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|x86.ActiveCfg = Debug|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|x86.Build.0 = Debug|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|Any CPU.Build.0 = Release|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|x64.ActiveCfg = Release|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|x64.Build.0 = Release|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|x86.ActiveCfg = Release|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/LightlessSync/FileCache/ExternalCompactionExecutor.cs b/LightlessSync/FileCache/ExternalCompactionExecutor.cs new file mode 100644 index 0000000..85c5a64 --- /dev/null +++ b/LightlessSync/FileCache/ExternalCompactionExecutor.cs @@ -0,0 +1,241 @@ +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.IO.Pipes; +using System.Text.Json; + +namespace LightlessSync.FileCache; + +internal sealed class ExternalCompactionExecutor : ICompactionExecutor, IDisposable +{ + private readonly ILogger _logger; + private readonly ICompactorContext _context; + private readonly TimeSpan _timeout = TimeSpan.FromMinutes(5); + private readonly string _pipeName; + private Process? _workerProcess; + private bool _disposed; + private readonly object _sync = new(); + + public ExternalCompactionExecutor(ILogger logger, ICompactorContext context) + { + _logger = logger; + _context = context; + _pipeName = $"LightlessCompactor-{Environment.ProcessId}"; + } + + public bool TryCompact(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + return false; + + if (!EnsureWorkerRunning()) + return false; + + try + { + var request = new CompactorRequest + { + Type = "compact", + Path = filePath + }; + + return SendRequest(request, out var response) && response?.Success == true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "External compactor failed for {file}", filePath); + return false; + } + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + + try + { + SendRequest(new CompactorRequest { Type = "shutdown" }, out _); + } + catch + { + // ignore + } + + lock (_sync) + { + if (_workerProcess is null) + return; + + TryKill(_workerProcess); + _workerProcess.Dispose(); + _workerProcess = null; + } + } + + private bool EnsureWorkerRunning() + { + lock (_sync) + { + if (_workerProcess is { HasExited: false }) + return true; + + _workerProcess?.Dispose(); + _workerProcess = null; + + var workerPath = ResolveWorkerPath(); + if (string.IsNullOrEmpty(workerPath)) + return false; + + var args = BuildArguments(); + var startInfo = new ProcessStartInfo + { + FileName = workerPath, + Arguments = args, + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + var process = new Process { StartInfo = startInfo }; + if (!process.Start()) + return false; + + TrySetLowPriority(process); + _ = DrainAsync(process.StandardOutput, "stdout"); + _ = DrainAsync(process.StandardError, "stderr"); + + _workerProcess = process; + return true; + } + } + + private bool SendRequest(CompactorRequest request, out CompactorResponse? response) + { + response = null; + using var pipe = new NamedPipeClientStream(".", _pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); + + try + { + pipe.Connect((int)_timeout.TotalMilliseconds); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Compactor pipe connection failed."); + return false; + } + + using var writer = new StreamWriter(pipe) { AutoFlush = true }; + using var reader = new StreamReader(pipe); + + var payload = JsonSerializer.Serialize(request); + writer.WriteLine(payload); + + var readTask = reader.ReadLineAsync(); + if (!readTask.Wait(_timeout)) + { + _logger.LogWarning("Compactor pipe timed out waiting for response."); + return false; + } + + var line = readTask.Result; + if (string.IsNullOrWhiteSpace(line)) + return false; + + try + { + response = JsonSerializer.Deserialize(line); + return response is not null; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to parse compactor response."); + return false; + } + } + + private string? ResolveWorkerPath() + { + var baseDir = AppContext.BaseDirectory; + var exeName = OperatingSystem.IsWindows() || _context.IsWine + ? "LightlessCompactorWorker.exe" + : "LightlessCompactorWorker"; + var path = Path.Combine(baseDir, exeName); + return File.Exists(path) ? path : null; + } + + private string BuildArguments() + { + var args = new List { "--pipe", Quote(_pipeName), "--parent", Environment.ProcessId.ToString() }; + if (_context.IsWine) + args.Add("--wine"); + return string.Join(' ', args); + } + + private static string Quote(string value) + { + if (string.IsNullOrEmpty(value)) + return "\"\""; + + if (!value.Contains('"', StringComparison.Ordinal)) + return "\"" + value + "\""; + + return "\"" + value.Replace("\"", "\\\"", StringComparison.Ordinal) + "\""; + } + + private static void TrySetLowPriority(Process process) + { + try + { + if (OperatingSystem.IsWindows()) + process.PriorityClass = ProcessPriorityClass.BelowNormal; + } + catch + { + // ignore + } + } + + private async Task DrainAsync(StreamReader reader, string label) + { + try + { + string? line; + while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) + { + if (_logger.IsEnabled(LogLevel.Trace)) + _logger.LogTrace("Compactor {label}: {line}", label, line); + } + } + catch + { + // ignore + } + } + + private static void TryKill(Process process) + { + try + { + process.Kill(entireProcessTree: true); + } + catch + { + // ignore + } + } + + private sealed class CompactorRequest + { + public string Type { get; init; } = "compact"; + public string? Path { get; init; } + } + + private sealed class CompactorResponse + { + public bool Success { get; init; } + public string? Error { get; init; } + } +} diff --git a/LightlessSync/FileCache/FileCacheManager.cs b/LightlessSync/FileCache/FileCacheManager.cs index b98b441..886f8cc 100644 --- a/LightlessSync/FileCache/FileCacheManager.cs +++ b/LightlessSync/FileCache/FileCacheManager.cs @@ -115,6 +115,35 @@ public sealed class FileCacheManager : IHostedService return true; } + private static bool TryGetHashFromFileName(FileInfo fileInfo, out string hash) + { + hash = Path.GetFileNameWithoutExtension(fileInfo.Name); + if (string.IsNullOrWhiteSpace(hash)) + { + return false; + } + + if (hash.Length is not (40 or 64)) + { + return false; + } + + for (var i = 0; i < hash.Length; i++) + { + var c = hash[i]; + var isHex = (c >= '0' && c <= '9') + || (c >= 'a' && c <= 'f') + || (c >= 'A' && c <= 'F'); + if (!isHex) + { + return false; + } + } + + hash = hash.ToUpperInvariant(); + return true; + } + private static string BuildVersionHeader() => $"{FileCacheVersionHeaderPrefix}{FileCacheVersion}"; private static bool TryParseVersionHeader(string? line, out int version) @@ -288,6 +317,11 @@ public sealed class FileCacheManager : IHostedService _logger.LogTrace("Creating cache entry for {path}", path); var cacheFolder = _configService.Current.CacheFolder; if (string.IsNullOrEmpty(cacheFolder)) return null; + if (TryGetHashFromFileName(fi, out var hash)) + { + return CreateCacheEntryWithKnownHash(fi.FullName, hash); + } + return CreateFileEntity(cacheFolder, CachePrefix, fi); } diff --git a/LightlessSync/FileCache/PluginCompactorContext.cs b/LightlessSync/FileCache/PluginCompactorContext.cs new file mode 100644 index 0000000..c466a94 --- /dev/null +++ b/LightlessSync/FileCache/PluginCompactorContext.cs @@ -0,0 +1,20 @@ +using LightlessSync.LightlessConfiguration; +using LightlessSync.Services; + +namespace LightlessSync.FileCache; + +internal sealed class PluginCompactorContext : ICompactorContext +{ + private readonly LightlessConfigService _configService; + private readonly DalamudUtilService _dalamudUtilService; + + public PluginCompactorContext(LightlessConfigService configService, DalamudUtilService dalamudUtilService) + { + _configService = configService; + _dalamudUtilService = dalamudUtilService; + } + + public bool UseCompactor => _configService.Current.UseCompactor; + public string CacheFolder => _configService.Current.CacheFolder; + public bool IsWine => _dalamudUtilService.IsWine; +} diff --git a/LightlessSync/FileCache/TransientResourceManager.cs b/LightlessSync/FileCache/TransientResourceManager.cs index 11073dc..1397159 100644 --- a/LightlessSync/FileCache/TransientResourceManager.cs +++ b/LightlessSync/FileCache/TransientResourceManager.cs @@ -25,7 +25,6 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase private readonly object _ownedHandlerLock = new(); private readonly string[] _handledFileTypes = ["tmb", "pap", "avfx", "atex", "sklb", "eid", "phyb", "scd", "skp", "shpk", "kdb"]; private readonly string[] _handledRecordingFileTypes = ["tex", "mdl", "mtrl"]; - private readonly string[] _handledFileTypesWithRecording; private readonly HashSet _playerRelatedPointers = []; private readonly object _playerRelatedLock = new(); private readonly ConcurrentDictionary _playerRelatedByAddress = new(); @@ -42,8 +41,6 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase _dalamudUtil = dalamudUtil; _actorObjectService = actorObjectService; _gameObjectHandlerFactory = gameObjectHandlerFactory; - _handledFileTypesWithRecording = _handledRecordingFileTypes.Concat(_handledFileTypes).ToArray(); - Mediator.Subscribe(this, Manager_PenumbraResourceLoadEvent); Mediator.Subscribe(this, msg => HandleActorTracked(msg.Descriptor)); Mediator.Subscribe(this, msg => HandleActorUntracked(msg.Descriptor)); @@ -523,46 +520,51 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase private void Manager_PenumbraResourceLoadEvent(PenumbraResourceLoadMessage msg) { + var gamePath = msg.GamePath.ToLowerInvariant(); var gameObjectAddress = msg.GameObject; - if (!_cachedFrameAddresses.TryGetValue(gameObjectAddress, out var objectKind)) - { - if (_actorObjectService.TryGetOwnedKind(gameObjectAddress, out var ownedKind)) - { - objectKind = ownedKind; - } - else - { - return; - } - } - - var gamePath = NormalizeGamePath(msg.GamePath); - if (string.IsNullOrEmpty(gamePath)) - { - return; - } + var filePath = msg.FilePath; // ignore files already processed this frame + if (_cachedHandledPaths.Contains(gamePath)) return; + lock (_cacheAdditionLock) { - if (!_cachedHandledPaths.Add(gamePath)) - { - return; - } + _cachedHandledPaths.Add(gamePath); + } + + // replace individual mtrl stuff + if (filePath.StartsWith("|", StringComparison.OrdinalIgnoreCase)) + { + filePath = filePath.Split("|")[2]; + } + // replace filepath + filePath = filePath.ToLowerInvariant().Replace("\\", "/", StringComparison.OrdinalIgnoreCase); + + // ignore files that are the same + var replacedGamePath = gamePath.ToLowerInvariant().Replace("\\", "/", StringComparison.OrdinalIgnoreCase); + if (string.Equals(filePath, replacedGamePath, StringComparison.OrdinalIgnoreCase)) + { + return; } // ignore files to not handle - var handledTypes = IsTransientRecording ? _handledFileTypesWithRecording : _handledFileTypes; - if (!HasHandledFileType(gamePath, handledTypes)) + var handledTypes = IsTransientRecording ? _handledRecordingFileTypes.Concat(_handledFileTypes) : _handledFileTypes; + if (!handledTypes.Any(type => gamePath.EndsWith(type, StringComparison.OrdinalIgnoreCase))) { + lock (_cacheAdditionLock) + { + _cachedHandledPaths.Add(gamePath); + } return; } - var filePath = NormalizeFilePath(msg.FilePath); - - // ignore files that are the same - if (string.Equals(filePath, gamePath, StringComparison.Ordinal)) + // ignore files not belonging to anything player related + if (!_cachedFrameAddresses.TryGetValue(gameObjectAddress, out var objectKind)) { + lock (_cacheAdditionLock) + { + _cachedHandledPaths.Add(gamePath); + } return; } @@ -577,12 +579,13 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase _playerRelatedByAddress.TryGetValue(gameObjectAddress, out var owner); bool alreadyTransient = false; - bool transientContains = transientResources.Contains(gamePath); - bool semiTransientContains = SemiTransientResources.Values.Any(value => value.Contains(gamePath)); + bool transientContains = transientResources.Contains(replacedGamePath); + bool semiTransientContains = SemiTransientResources.SelectMany(k => k.Value) + .Any(f => string.Equals(f, gamePath, StringComparison.OrdinalIgnoreCase)); if (transientContains || semiTransientContains) { if (!IsTransientRecording) - Logger.LogTrace("Not adding {replacedPath} => {filePath}, Reason: Transient: {contains}, SemiTransient: {contains2}", gamePath, filePath, + Logger.LogTrace("Not adding {replacedPath} => {filePath}, Reason: Transient: {contains}, SemiTransient: {contains2}", replacedGamePath, filePath, transientContains, semiTransientContains); alreadyTransient = true; } @@ -590,10 +593,10 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase { if (!IsTransientRecording) { - bool isAdded = transientResources.Add(gamePath); + bool isAdded = transientResources.Add(replacedGamePath); if (isAdded) { - Logger.LogDebug("Adding {replacedGamePath} for {gameObject} ({filePath})", gamePath, owner?.ToString() ?? gameObjectAddress.ToString("X"), filePath); + Logger.LogDebug("Adding {replacedGamePath} for {gameObject} ({filePath})", replacedGamePath, owner?.ToString() ?? gameObjectAddress.ToString("X"), filePath); SendTransients(gameObjectAddress, objectKind); } } @@ -601,7 +604,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase if (owner != null && IsTransientRecording) { - _recordedTransients.Add(new TransientRecord(owner, gamePath, filePath, alreadyTransient) { AddTransient = !alreadyTransient }); + _recordedTransients.Add(new TransientRecord(owner, replacedGamePath, filePath, alreadyTransient) { AddTransient = !alreadyTransient }); } } @@ -700,4 +703,4 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase { public bool AddTransient { get; set; } } -} \ No newline at end of file +} diff --git a/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs b/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs index e077eab..db63c2a 100644 --- a/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs +++ b/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs @@ -4,7 +4,6 @@ using LightlessSync.Interop.Ipc.Penumbra; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.PlayerData.Handlers; using LightlessSync.Services; -using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; using Penumbra.Api.Enums; @@ -36,8 +35,7 @@ public sealed class IpcCallerPenumbra : IpcServiceBase IDalamudPluginInterface pluginInterface, DalamudUtilService dalamudUtil, LightlessMediator mediator, - RedrawManager redrawManager, - ActorObjectService actorObjectService) : base(logger, mediator, pluginInterface, PenumbraDescriptor) + RedrawManager redrawManager) : base(logger, mediator, pluginInterface, PenumbraDescriptor) { _penumbraEnabled = new GetEnabledState(pluginInterface); _penumbraGetModDirectory = new GetModDirectory(pluginInterface); @@ -46,7 +44,7 @@ public sealed class IpcCallerPenumbra : IpcServiceBase _penumbraModSettingChanged = ModSettingChanged.Subscriber(pluginInterface, HandlePenumbraModSettingChanged); _collections = RegisterInterop(new PenumbraCollections(logger, pluginInterface, dalamudUtil, mediator)); - _resources = RegisterInterop(new PenumbraResource(logger, pluginInterface, dalamudUtil, mediator, actorObjectService)); + _resources = RegisterInterop(new PenumbraResource(logger, pluginInterface, dalamudUtil, mediator)); _redraw = RegisterInterop(new PenumbraRedraw(logger, pluginInterface, dalamudUtil, mediator, redrawManager)); _textures = RegisterInterop(new PenumbraTexture(logger, pluginInterface, dalamudUtil, mediator, _redraw)); @@ -104,8 +102,8 @@ public sealed class IpcCallerPenumbra : IpcServiceBase public Task RedrawAsync(ILogger logger, GameObjectHandler handler, Guid applicationId, CancellationToken token) => _redraw.RedrawAsync(logger, handler, applicationId, token); - public Task ConvertTextureFiles(ILogger logger, IReadOnlyList jobs, IProgress? progress, CancellationToken token) - => _textures.ConvertTextureFilesAsync(logger, jobs, progress, token); + public Task ConvertTextureFiles(ILogger logger, IReadOnlyList jobs, IProgress? progress, CancellationToken token, bool requestRedraw = true) + => _textures.ConvertTextureFilesAsync(logger, jobs, progress, token, requestRedraw); public Task ConvertTextureFileDirectAsync(TextureConversionJob job, CancellationToken token) => _textures.ConvertTextureFileDirectAsync(job, token); diff --git a/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs b/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs index 73da7cc..19a1e7f 100644 --- a/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs +++ b/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs @@ -2,9 +2,9 @@ using Dalamud.Plugin; using LightlessSync.Interop.Ipc.Framework; using LightlessSync.PlayerData.Handlers; using LightlessSync.Services; -using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; +using System.Globalization; using Penumbra.Api.Helpers; using Penumbra.Api.IpcSubscribers; @@ -12,7 +12,6 @@ namespace LightlessSync.Interop.Ipc.Penumbra; public sealed class PenumbraResource : PenumbraBase { - private readonly ActorObjectService _actorObjectService; private readonly GetGameObjectResourcePaths _gameObjectResourcePaths; private readonly ResolveGameObjectPath _resolveGameObjectPath; private readonly ReverseResolveGameObjectPath _reverseResolveGameObjectPath; @@ -24,10 +23,8 @@ public sealed class PenumbraResource : PenumbraBase ILogger logger, IDalamudPluginInterface pluginInterface, DalamudUtilService dalamudUtil, - LightlessMediator mediator, - ActorObjectService actorObjectService) : base(logger, pluginInterface, dalamudUtil, mediator) + LightlessMediator mediator) : base(logger, pluginInterface, dalamudUtil, mediator) { - _actorObjectService = actorObjectService; _gameObjectResourcePaths = new GetGameObjectResourcePaths(pluginInterface); _resolveGameObjectPath = new ResolveGameObjectPath(pluginInterface); _reverseResolveGameObjectPath = new ReverseResolveGameObjectPath(pluginInterface); @@ -79,22 +76,10 @@ public sealed class PenumbraResource : PenumbraBase private void HandleResourceLoaded(nint ptr, string gamePath, string resolvedPath) { - if (ptr == nint.Zero) + if (ptr != nint.Zero && string.Compare(gamePath, resolvedPath, ignoreCase: true, CultureInfo.InvariantCulture) != 0) { - return; + Mediator.Publish(new PenumbraResourceLoadMessage(ptr, gamePath, resolvedPath)); } - - if (!_actorObjectService.TryGetOwnedKind(ptr, out _)) - { - return; - } - - if (string.Compare(gamePath, resolvedPath, StringComparison.OrdinalIgnoreCase) == 0) - { - return; - } - - Mediator.Publish(new PenumbraResourceLoadMessage(ptr, gamePath, resolvedPath)); } protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current) diff --git a/LightlessSync/Interop/Ipc/Penumbra/PenumbraTexture.cs b/LightlessSync/Interop/Ipc/Penumbra/PenumbraTexture.cs index e12fd7b..453d211 100644 --- a/LightlessSync/Interop/Ipc/Penumbra/PenumbraTexture.cs +++ b/LightlessSync/Interop/Ipc/Penumbra/PenumbraTexture.cs @@ -26,7 +26,7 @@ public sealed class PenumbraTexture : PenumbraBase public override string Name => "Penumbra.Textures"; - public async Task ConvertTextureFilesAsync(ILogger logger, IReadOnlyList jobs, IProgress? progress, CancellationToken token) + public async Task ConvertTextureFilesAsync(ILogger logger, IReadOnlyList jobs, IProgress? progress, CancellationToken token, bool requestRedraw) { if (!IsAvailable || jobs.Count == 0) { @@ -57,7 +57,7 @@ public sealed class PenumbraTexture : PenumbraBase Mediator.Publish(new ResumeScanMessage(nameof(ConvertTextureFilesAsync))); } - if (completedJobs > 0 && !token.IsCancellationRequested) + if (requestRedraw && completedJobs > 0 && !token.IsCancellationRequested) { await DalamudUtil.RunOnFrameworkThread(async () => { diff --git a/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs index 5532d78..48db57e 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs @@ -12,6 +12,9 @@ public sealed class ChatConfig : ILightlessConfiguration public bool ShowMessageTimestamps { get; set; } = true; public bool ShowNotesInSyncshellChat { get; set; } = true; public bool EnableAnimatedEmotes { get; set; } = true; + public float EmoteScale { get; set; } = 1.5f; + public bool EnableMentionNotifications { get; set; } = true; + public bool AutoOpenChatOnNewMessage { get; set; } = false; public float ChatWindowOpacity { get; set; } = .97f; public bool FadeWhenUnfocused { get; set; } = false; public float UnfocusedWindowOpacity { get; set; } = 0.6f; @@ -23,6 +26,9 @@ public sealed class ChatConfig : ILightlessConfiguration public bool ShowWhenUiHidden { get; set; } = true; public bool ShowInCutscenes { get; set; } = true; public bool ShowInGpose { get; set; } = true; + public bool PersistSyncshellHistory { get; set; } = false; public List ChannelOrder { get; set; } = new(); + public Dictionary HiddenChannels { get; set; } = new(StringComparer.Ordinal); + public Dictionary SyncshellChannelHistory { get; set; } = new(StringComparer.Ordinal); public Dictionary PreferNotesForChannels { get; set; } = new(StringComparer.Ordinal); } diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index 8f1a3de..35c1958 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -32,6 +32,8 @@ public class LightlessConfig : ILightlessConfiguration public DtrEntry.Colors DtrColorsLightfinderUnavailable { get; set; } = new(Foreground: 0x000000u, Glow: 0x000000u); public LightfinderDtrDisplayMode LightfinderDtrDisplayMode { get; set; } = LightfinderDtrDisplayMode.PendingPairRequests; public bool UseLightlessRedesign { get; set; } = true; + public bool ShowUiWhenUiHidden { get; set; } = true; + public bool ShowUiInGpose { get; set; } = true; public bool EnableRightClickMenus { get; set; } = true; public NotificationLocation ErrorNotification { get; set; } = NotificationLocation.Both; public string ExportFolder { get; set; } = string.Empty; diff --git a/LightlessSync/LightlessConfiguration/Configurations/PlayerPerformanceConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/PlayerPerformanceConfig.cs index 462a63f..599bea1 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/PlayerPerformanceConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/PlayerPerformanceConfig.cs @@ -21,11 +21,14 @@ public class PlayerPerformanceConfig : ILightlessConfiguration public bool EnableIndexTextureDownscale { get; set; } = false; public int TextureDownscaleMaxDimension { get; set; } = 2048; public bool OnlyDownscaleUncompressedTextures { get; set; } = true; + public bool EnableUncompressedTextureCompression { get; set; } = false; + public bool SkipUncompressedTextureCompressionMipMaps { get; set; } = false; public bool KeepOriginalTextureFiles { get; set; } = false; public bool SkipTextureDownscaleForPreferredPairs { get; set; } = true; public bool EnableModelDecimation { get; set; } = false; - public int ModelDecimationTriangleThreshold { get; set; } = 20_000; + public int ModelDecimationTriangleThreshold { get; set; } = 15_000; public double ModelDecimationTargetRatio { get; set; } = 0.8; + public bool ModelDecimationNormalizeTangents { get; set; } = true; public bool KeepOriginalModelFiles { get; set; } = true; public bool SkipModelDecimationForPreferredPairs { get; set; } = true; public bool ModelDecimationAllowBody { get; set; } = false; diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 938d413..d201a7b 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -78,6 +78,8 @@ + + @@ -101,5 +103,13 @@ + + + + + + + + diff --git a/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs b/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs index 211a6fc..feb6d41 100644 --- a/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs +++ b/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs @@ -19,6 +19,7 @@ public class FileDownloadManagerFactory private readonly TextureDownscaleService _textureDownscaleService; private readonly ModelDecimationService _modelDecimationService; private readonly TextureMetadataHelper _textureMetadataHelper; + private readonly FileDownloadDeduplicator _downloadDeduplicator; public FileDownloadManagerFactory( ILoggerFactory loggerFactory, @@ -29,7 +30,8 @@ public class FileDownloadManagerFactory LightlessConfigService configService, TextureDownscaleService textureDownscaleService, ModelDecimationService modelDecimationService, - TextureMetadataHelper textureMetadataHelper) + TextureMetadataHelper textureMetadataHelper, + FileDownloadDeduplicator downloadDeduplicator) { _loggerFactory = loggerFactory; _lightlessMediator = lightlessMediator; @@ -40,6 +42,7 @@ public class FileDownloadManagerFactory _textureDownscaleService = textureDownscaleService; _modelDecimationService = modelDecimationService; _textureMetadataHelper = textureMetadataHelper; + _downloadDeduplicator = downloadDeduplicator; } public FileDownloadManager Create() @@ -53,6 +56,7 @@ public class FileDownloadManagerFactory _configService, _textureDownscaleService, _modelDecimationService, - _textureMetadataHelper); + _textureMetadataHelper, + _downloadDeduplicator); } } diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index 9141a9b..744e503 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -476,7 +476,7 @@ public class PlayerDataFactory if (transientPaths.Count == 0) return (new Dictionary(StringComparer.Ordinal), clearedReplacements); - var resolved = await GetFileReplacementsFromPaths(obj, transientPaths, new HashSet(StringComparer.Ordinal)) + var resolved = await GetFileReplacementsFromPaths(transientPaths, new HashSet(StringComparer.Ordinal)) .ConfigureAwait(false); if (_maxTransientResolvedEntries > 0 && resolved.Count > _maxTransientResolvedEntries) @@ -692,7 +692,6 @@ public class PlayerDataFactory private async Task> GetFileReplacementsFromPaths( - GameObjectHandler handler, HashSet forwardResolve, HashSet reverseResolve) { @@ -707,59 +706,6 @@ public class PlayerDataFactory var reversePathsLower = reversePaths.Length == 0 ? Array.Empty() : 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++) diff --git a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs index 0566491..d04cc3b 100644 --- a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs @@ -1,43 +1,44 @@ - using LightlessSync.API.Data; +using LightlessSync.API.Data; - namespace LightlessSync.PlayerData.Pairs; +namespace LightlessSync.PlayerData.Pairs; - /// - /// orchestrates the lifecycle of a paired character - /// - public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject - { - new string Ident { get; } - bool Initialized { get; } - bool IsVisible { get; } - bool ScheduledForDeletion { get; set; } - CharacterData? LastReceivedCharacterData { get; } - long LastAppliedDataBytes { get; } - new string? PlayerName { get; } - string PlayerNameHash { get; } - uint PlayerCharacterId { get; } - DateTime? LastDataReceivedAt { get; } - DateTime? LastApplyAttemptAt { get; } - DateTime? LastSuccessfulApplyAt { get; } - string? LastFailureReason { get; } - IReadOnlyList LastBlockingConditions { get; } - bool IsApplying { get; } - bool IsDownloading { get; } - int PendingDownloadCount { get; } - int ForbiddenDownloadCount { get; } - bool PendingModReapply { get; } - bool ModApplyDeferred { get; } - int MissingCriticalMods { get; } - int MissingNonCriticalMods { get; } - int MissingForbiddenMods { get; } - DateTime? InvisibleSinceUtc { get; } - DateTime? VisibilityEvictionDueAtUtc { get; } +/// +/// orchestrates the lifecycle of a paired character +/// +public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject +{ + new string Ident { get; } + bool Initialized { get; } + bool IsVisible { get; } + bool ScheduledForDeletion { get; set; } + CharacterData? LastReceivedCharacterData { get; } + long LastAppliedDataBytes { get; } + new string? PlayerName { get; } + string PlayerNameHash { get; } + uint PlayerCharacterId { get; } + DateTime? LastDataReceivedAt { get; } + DateTime? LastApplyAttemptAt { get; } + DateTime? LastSuccessfulApplyAt { get; } + string? LastFailureReason { get; } + IReadOnlyList LastBlockingConditions { get; } + bool IsApplying { get; } + bool IsDownloading { get; } + int PendingDownloadCount { get; } + int ForbiddenDownloadCount { get; } + bool PendingModReapply { get; } + bool ModApplyDeferred { get; } + int MissingCriticalMods { get; } + int MissingNonCriticalMods { get; } + int MissingForbiddenMods { get; } + DateTime? InvisibleSinceUtc { get; } + DateTime? VisibilityEvictionDueAtUtc { get; } void Initialize(); - void ApplyData(CharacterData data); - void ApplyLastReceivedData(bool forced = false); - bool FetchPerformanceMetricsFromCache(); - void LoadCachedCharacterData(CharacterData data); - void SetUploading(bool uploading); - void SetPaused(bool paused); - } + void ApplyData(CharacterData data); + void ApplyLastReceivedData(bool forced = false); + Task EnsurePerformanceMetricsAsync(CancellationToken cancellationToken); + bool FetchPerformanceMetricsFromCache(); + void LoadCachedCharacterData(CharacterData data); + void SetUploading(bool uploading); + void SetPaused(bool paused); +} diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index b392e62..c4f3e70 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -54,6 +54,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private readonly XivDataAnalyzer _modelAnalyzer; private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor; private readonly LightlessConfigService _configService; + private readonly SemaphoreSlim _metricsComputeGate = new(1, 1); private readonly PairManager _pairManager; private readonly IFramework _framework; private CancellationTokenSource? _applicationCancellationTokenSource; @@ -193,8 +194,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa public string? LastFailureReason => _lastFailureReason; public IReadOnlyList LastBlockingConditions => _lastBlockingConditions; public bool IsApplying => _applicationTask is { IsCompleted: false }; - public bool IsDownloading => _downloadManager.IsDownloading; - public int PendingDownloadCount => _downloadManager.CurrentDownloads.Count; + public bool IsDownloading => _downloadManager.IsDownloadingFor(_charaHandler); + public int PendingDownloadCount => _downloadManager.GetPendingDownloadCount(_charaHandler); public int ForbiddenDownloadCount => _downloadManager.ForbiddenTransfers.Count; public PairHandlerAdapter( @@ -721,6 +722,74 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return true; } + public async Task EnsurePerformanceMetricsAsync(CancellationToken cancellationToken) + { + EnsureInitialized(); + + if (LastReceivedCharacterData is null || IsApplying) + { + return; + } + + if (LastAppliedApproximateVRAMBytes >= 0 + && LastAppliedDataTris >= 0 + && LastAppliedApproximateEffectiveVRAMBytes >= 0 + && LastAppliedApproximateEffectiveTris >= 0) + { + return; + } + + await _metricsComputeGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + cancellationToken.ThrowIfCancellationRequested(); + + if (LastReceivedCharacterData is null) + { + return; + } + + if (LastAppliedApproximateVRAMBytes >= 0 + && LastAppliedDataTris >= 0 + && LastAppliedApproximateEffectiveVRAMBytes >= 0 + && LastAppliedApproximateEffectiveTris >= 0) + { + return; + } + + var sanitized = CloneAndSanitizeLastReceived(out var dataHash); + if (sanitized is null) + { + return; + } + + if (!string.IsNullOrEmpty(dataHash) && TryApplyCachedMetrics(dataHash)) + { + _cachedData = sanitized; + _pairStateCache.Store(Ident, sanitized); + return; + } + + if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0) + { + _playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, sanitized, []); + } + + if (LastAppliedDataTris < 0 || LastAppliedApproximateEffectiveTris < 0) + { + await _playerPerformanceService.CheckTriangleUsageThresholds(this, sanitized).ConfigureAwait(false); + } + + StorePerformanceMetrics(sanitized); + _cachedData = sanitized; + _pairStateCache.Store(Ident, sanitized); + } + finally + { + _metricsComputeGate.Release(); + } + } + private CharacterData? CloneAndSanitizeLastReceived(out string? dataHash) { dataHash = null; @@ -1090,6 +1159,19 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods); } + var forceModsForMissing = _pendingModReapply; + if (!forceModsForMissing && HasMissingCachedFiles(characterData)) + { + forceModsForMissing = true; + } + + if (forceModsForMissing) + { + _forceApplyMods = true; + } + + var suppressForcedModRedrawOnForcedApply = suppressForcedModRedraw || forceModsForMissing; + SetUploading(false); Logger.LogDebug("[BASE-{appbase}] Applying data for {player}, forceApplyCustomization: {forced}, forceApplyMods: {forceMods}", applicationBase, GetLogIdentifier(), forceApplyCustomization, _forceApplyMods); @@ -1106,7 +1188,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa "Applying Character Data"))); var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this, - forceApplyCustomization, _forceApplyMods, suppressForcedModRedraw); + forceApplyCustomization, _forceApplyMods, suppressForcedModRedrawOnForcedApply); if (handlerReady && _forceApplyMods) { @@ -1921,7 +2003,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } var handlerForDownload = _charaHandler; - _pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, downloadToken, skipDownscaleForPair, skipDecimationForPair).ConfigureAwait(false)); + _pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, toDownloadFiles, downloadToken, skipDownscaleForPair, skipDecimationForPair).ConfigureAwait(false)); await _pairDownloadTask.ConfigureAwait(false); @@ -2136,6 +2218,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } SplitPapMappings(moddedPaths, out var withoutPap, out var papOnly); + var hasPap = papOnly.Count > 0; await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, objIndex.Value).ConfigureAwait(false); @@ -2148,22 +2231,29 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (handlerForApply.Address != nint.Zero) await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token).ConfigureAwait(false); - var removedPap = await StripIncompatiblePapAsync(handlerForApply, charaData, papOnly, token).ConfigureAwait(false); - if (removedPap > 0) + if (hasPap) { - Logger.LogTrace("[{applicationId}] Removed {removedPap} incompatible PAP mappings found for {handler}", _applicationId, removedPap, GetLogIdentifier()); + var removedPap = await StripIncompatiblePapAsync(handlerForApply, charaData, papOnly, token).ConfigureAwait(false); + if (removedPap > 0) + { + Logger.LogTrace("[{applicationId}] Removed {removedPap} incompatible PAP mappings found for {handler}", _applicationId, removedPap, GetLogIdentifier()); + } + + var merged = new Dictionary<(string GamePath, string? Hash), string>(withoutPap, withoutPap.Comparer); + foreach (var kv in papOnly) + merged[kv.Key] = kv.Value; + + await _ipcManager.Penumbra.SetTemporaryModsAsync( + Logger, _applicationId, penumbraCollection, + merged.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)) + .ConfigureAwait(false); + + _lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(merged, merged.Comparer); + } + else + { + _lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(withoutPap, withoutPap.Comparer); } - - var merged = new Dictionary<(string GamePath, string? Hash), string>(withoutPap, withoutPap.Comparer); - foreach (var kv in papOnly) - merged[kv.Key] = kv.Value; - - await _ipcManager.Penumbra.SetTemporaryModsAsync( - Logger, _applicationId, penumbraCollection, - merged.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)) - .ConfigureAwait(false); - - _lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(merged, merged.Comparer); LastAppliedDataBytes = -1; foreach (var path in moddedPaths.Values.Distinct(StringComparer.OrdinalIgnoreCase).Select(v => new FileInfo(v)).Where(p => p.Exists)) @@ -2218,20 +2308,20 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _lastSuccessfulApplyAt = DateTime.UtcNow; ClearFailureState(); Logger.LogDebug("[{applicationId}] Application finished", _applicationId); - } - catch (OperationCanceledException) - { - Logger.LogDebug("[{applicationId}] Application cancelled for {handler}", _applicationId, GetLogIdentifier()); - _cachedData = charaData; - _pairStateCache.Store(Ident, charaData); - _forceFullReapply = true; - RecordFailure("Application cancelled", "Cancellation"); - } - catch (Exception ex) - { - if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException)) + } + catch (OperationCanceledException) { - IsVisible = false; + Logger.LogDebug("[{applicationId}] Application cancelled for {handler}", _applicationId, GetLogIdentifier()); + _cachedData = charaData; + _pairStateCache.Store(Ident, charaData); + _forceFullReapply = true; + RecordFailure("Application cancelled", "Cancellation"); + } + catch (Exception ex) + { + if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException)) + { + IsVisible = false; _forceApplyMods = true; _cachedData = charaData; _pairStateCache.Store(Ident, charaData); @@ -2471,6 +2561,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa (item) => { token.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(item.Hash)) + { + Logger.LogTrace("[BASE-{appBase}] Skipping replacement with empty hash for paths: {paths}", applicationBase, string.Join(", ", item.GamePaths)); + return; + } var fileCache = _fileDbManager.GetFileCacheByHash(item.Hash); if (fileCache is not null && !File.Exists(fileCache.ResolvedFilepath)) { @@ -2698,10 +2793,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa ApplyCharacterData(pending.ApplicationId, pending.CharacterData, pending.Forced); } catch (Exception ex) - { - Logger.LogError(ex, "Failed applying queued data for {handler}", GetLogIdentifier()); - } - }); + { + Logger.LogError(ex, "Failed applying queued data for {handler}", GetLogIdentifier()); + } + }); } private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor) diff --git a/LightlessSync/PlayerData/Pairs/PairLedger.cs b/LightlessSync/PlayerData/Pairs/PairLedger.cs index fdb226e..2fe2205 100644 --- a/LightlessSync/PlayerData/Pairs/PairLedger.cs +++ b/LightlessSync/PlayerData/Pairs/PairLedger.cs @@ -271,7 +271,20 @@ public sealed class PairLedger : DisposableMediatorSubscriberBase try { - handler.ApplyLastReceivedData(forced: true); + _ = Task.Run(async () => + { + try + { + await handler.EnsurePerformanceMetricsAsync(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug(ex, "Failed to ensure performance metrics for {Ident}", handler.Ident); + } + } + }); } catch (Exception ex) { diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 4e1ed4e..f14aeda 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -129,12 +129,15 @@ public sealed class Plugin : IDalamudPlugin services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -331,8 +334,7 @@ public sealed class Plugin : IDalamudPlugin pluginInterface, sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService())); services.AddSingleton(sp => new IpcCallerGlamourer( sp.GetRequiredService>(), @@ -516,6 +518,7 @@ public sealed class Plugin : IDalamudPlugin sp.GetRequiredService>(), pluginInterface.UiBuilder, sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), sp.GetServices(), sp.GetRequiredService(), diff --git a/LightlessSync/Services/ActorTracking/ActorObjectService.cs b/LightlessSync/Services/ActorTracking/ActorObjectService.cs index a00839e..bb9ce7a 100644 --- a/LightlessSync/Services/ActorTracking/ActorObjectService.cs +++ b/LightlessSync/Services/ActorTracking/ActorObjectService.cs @@ -571,36 +571,19 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS if (localPlayerAddress == nint.Zero) return nint.Zero; - 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 (ResolveOwnerId(candidate) == ownerEntityId) - return candidateAddress; - } - } + var playerObject = (GameObject*)localPlayerAddress; + var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1); + if (candidateAddress == nint.Zero) + return nint.Zero; - foreach (var obj in _objectTable) - { - if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress) - continue; - - if (obj.ObjectKind is not (DalamudObjectKind.MountType or DalamudObjectKind.Companion)) - continue; - - var candidate = (GameObject*)obj.Address; - if (ResolveOwnerId(candidate) == ownerEntityId) - return obj.Address; - } - - return nint.Zero; + var candidate = (GameObject*)candidateAddress; + var candidateKind = (DalamudObjectKind)candidate->ObjectKind; + return candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion + ? candidateAddress + : nint.Zero; } private unsafe nint GetPetAddress(nint localPlayerAddress, uint ownerEntityId) @@ -620,22 +603,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS } } - foreach (var obj in _objectTable) - { - if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress) - continue; - - if (obj.ObjectKind != DalamudObjectKind.BattleNpc) - continue; - - var candidate = (GameObject*)obj.Address; - if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet) - continue; - - if (ResolveOwnerId(candidate) == ownerEntityId) - return obj.Address; - } - return nint.Zero; } @@ -655,23 +622,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS return candidate; } } - - foreach (var obj in _objectTable) - { - if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress) - continue; - - if (obj.ObjectKind != DalamudObjectKind.BattleNpc) - continue; - - var candidate = (GameObject*)obj.Address; - if (candidate->BattleNpcSubKind != BattleNpcSubKind.Buddy) - continue; - - if (ResolveOwnerId(candidate) == ownerEntityId) - return obj.Address; - } - return nint.Zero; } diff --git a/LightlessSync/Services/Chat/ZoneChatService.cs b/LightlessSync/Services/Chat/ZoneChatService.cs index 54dd2d9..67ae117 100644 --- a/LightlessSync/Services/Chat/ZoneChatService.cs +++ b/LightlessSync/Services/Chat/ZoneChatService.cs @@ -8,18 +8,26 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using LightlessSync.UI.Services; using LightlessSync.LightlessConfiguration; +using LightlessSync.LightlessConfiguration.Models; +using System.Text.Json; +using System.Text.Json.Serialization; namespace LightlessSync.Services.Chat; public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedService { - private const int MaxMessageHistory = 150; + private const int MaxMessageHistory = 200; internal const int MaxOutgoingLength = 200; private const int MaxUnreadCount = 999; private const string ZoneUnavailableMessage = "Zone chat is only available in major cities."; private const string ZoneChannelKey = "zone"; private const int MaxReportReasonLength = 100; private const int MaxReportContextLength = 1000; + private static readonly JsonSerializerOptions PersistedHistorySerializerOptions = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; private readonly ApiController _apiController; private readonly DalamudUtilService _dalamudUtilService; @@ -376,6 +384,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS public Task StartAsync(CancellationToken cancellationToken) { + LoadPersistedSyncshellHistory(); Mediator.Subscribe(this, _ => HandleLogin()); Mediator.Subscribe(this, _ => HandleLogout()); Mediator.Subscribe(this, _ => ScheduleZonePresenceUpdate()); @@ -1000,11 +1009,22 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS private void OnChatMessageReceived(ChatMessageDto dto) { - var descriptor = dto.Channel.WithNormalizedCustomKey(); - var key = descriptor.Type == ChatChannelType.Zone ? ZoneChannelKey : BuildChannelKey(descriptor); - var fromSelf = IsMessageFromSelf(dto, key); - var message = BuildMessage(dto, fromSelf); + ChatChannelDescriptor descriptor = dto.Channel.WithNormalizedCustomKey(); + string key = descriptor.Type == ChatChannelType.Zone ? ZoneChannelKey : BuildChannelKey(descriptor); + bool fromSelf = IsMessageFromSelf(dto, key); + ChatMessageEntry message = BuildMessage(dto, fromSelf); + bool mentionNotificationsEnabled = _chatConfigService.Current.EnableMentionNotifications; + bool notifyMention = mentionNotificationsEnabled + && !fromSelf + && descriptor.Type == ChatChannelType.Group + && TryGetSelfMentionToken(dto.Message, out _); + + string? mentionChannelName = null; + string? mentionSenderName = null; bool publishChannelList = false; + bool shouldPersistHistory = _chatConfigService.Current.PersistSyncshellHistory; + List? persistedMessages = null; + string? persistedChannelKey = null; using (_sync.EnterScope()) { @@ -1042,6 +1062,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS state.Messages.RemoveAt(0); } + if (notifyMention) + { + mentionChannelName = state.DisplayName; + mentionSenderName = message.DisplayName; + } + if (string.Equals(_activeChannelKey, key, StringComparison.Ordinal)) { state.HasUnread = false; @@ -1058,10 +1084,29 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS } MarkChannelsSnapshotDirtyLocked(); + + if (shouldPersistHistory && state.Type == ChatChannelType.Group) + { + persistedChannelKey = state.Key; + persistedMessages = BuildPersistedHistoryLocked(state); + } } Mediator.Publish(new ChatChannelMessageAdded(key, message)); + if (persistedMessages is not null && persistedChannelKey is not null) + { + PersistSyncshellHistory(persistedChannelKey, persistedMessages); + } + + if (notifyMention) + { + string channelName = mentionChannelName ?? "Syncshell"; + string senderName = mentionSenderName ?? "Someone"; + string notificationText = $"You were mentioned by {senderName} in {channelName}."; + Mediator.Publish(new NotificationMessage("Syncshell mention", notificationText, NotificationType.Info)); + } + if (publishChannelList) { using (_sync.EnterScope()) @@ -1108,6 +1153,113 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS return false; } + private bool TryGetSelfMentionToken(string message, out string matchedToken) + { + matchedToken = string.Empty; + if (string.IsNullOrWhiteSpace(message)) + { + return false; + } + + HashSet tokens = BuildSelfMentionTokens(); + if (tokens.Count == 0) + { + return false; + } + + return TryFindMentionToken(message, tokens, out matchedToken); + } + + private HashSet BuildSelfMentionTokens() + { + HashSet tokens = new(StringComparer.OrdinalIgnoreCase); + string uid = _apiController.UID; + if (IsValidMentionToken(uid)) + { + tokens.Add(uid); + } + + string displayName = _apiController.DisplayName; + if (IsValidMentionToken(displayName)) + { + tokens.Add(displayName); + } + + return tokens; + } + + private static bool IsValidMentionToken(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + for (int i = 0; i < value.Length; i++) + { + if (!IsMentionChar(value[i])) + { + return false; + } + } + + return true; + } + + private static bool TryFindMentionToken(string message, IReadOnlyCollection tokens, out string matchedToken) + { + matchedToken = string.Empty; + if (tokens.Count == 0 || string.IsNullOrEmpty(message)) + { + return false; + } + + int index = 0; + while (index < message.Length) + { + if (message[index] != '@') + { + index++; + continue; + } + + if (index > 0 && IsMentionChar(message[index - 1])) + { + index++; + continue; + } + + int start = index + 1; + int end = start; + while (end < message.Length && IsMentionChar(message[end])) + { + end++; + } + + if (end == start) + { + index++; + continue; + } + + string token = message.Substring(start, end - start); + if (tokens.Contains(token)) + { + matchedToken = token; + return true; + } + + index = end; + } + + return false; + } + + private static bool IsMentionChar(char value) + { + return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '\''; + } + private ChatMessageEntry BuildMessage(ChatMessageDto dto, bool fromSelf) { var displayName = ResolveDisplayName(dto, fromSelf); @@ -1364,6 +1516,313 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS return 0; } + private void LoadPersistedSyncshellHistory() + { + if (!_chatConfigService.Current.PersistSyncshellHistory) + { + return; + } + + Dictionary persisted = _chatConfigService.Current.SyncshellChannelHistory; + if (persisted.Count == 0) + { + return; + } + + List invalidKeys = new(); + foreach (KeyValuePair entry in persisted) + { + if (string.IsNullOrWhiteSpace(entry.Key) || string.IsNullOrWhiteSpace(entry.Value)) + { + invalidKeys.Add(entry.Key); + continue; + } + + if (!TryDecodePersistedHistory(entry.Value, out List persistedMessages)) + { + invalidKeys.Add(entry.Key); + continue; + } + + if (persistedMessages.Count == 0) + { + invalidKeys.Add(entry.Key); + continue; + } + + if (persistedMessages.Count > MaxMessageHistory) + { + int startIndex = Math.Max(0, persistedMessages.Count - MaxMessageHistory); + persistedMessages = persistedMessages.GetRange(startIndex, persistedMessages.Count - startIndex); + } + + List restoredMessages = new(persistedMessages.Count); + foreach (PersistedChatMessage persistedMessage in persistedMessages) + { + if (!TryBuildRestoredMessage(entry.Key, persistedMessage, out ChatMessageEntry restoredMessage)) + { + continue; + } + + restoredMessages.Add(restoredMessage); + } + + if (restoredMessages.Count == 0) + { + invalidKeys.Add(entry.Key); + continue; + } + + using (_sync.EnterScope()) + { + _messageHistoryCache[entry.Key] = restoredMessages; + } + } + + if (invalidKeys.Count > 0) + { + foreach (string key in invalidKeys) + { + persisted.Remove(key); + } + + _chatConfigService.Save(); + } + } + + private List BuildPersistedHistoryLocked(ChatChannelState state) + { + int startIndex = Math.Max(0, state.Messages.Count - MaxMessageHistory); + List persistedMessages = new(state.Messages.Count - startIndex); + for (int i = startIndex; i < state.Messages.Count; i++) + { + ChatMessageEntry entry = state.Messages[i]; + if (entry.Payload is not { } payload) + { + continue; + } + + persistedMessages.Add(new PersistedChatMessage( + payload.Message, + entry.DisplayName, + entry.FromSelf, + entry.ReceivedAtUtc, + payload.SentAtUtc)); + } + + return persistedMessages; + } + + private void PersistSyncshellHistory(string channelKey, List persistedMessages) + { + if (!_chatConfigService.Current.PersistSyncshellHistory) + { + return; + } + + Dictionary persisted = _chatConfigService.Current.SyncshellChannelHistory; + if (persistedMessages.Count == 0) + { + if (persisted.Remove(channelKey)) + { + _chatConfigService.Save(); + } + + return; + } + + string? base64 = EncodePersistedMessages(persistedMessages); + if (string.IsNullOrWhiteSpace(base64)) + { + if (persisted.Remove(channelKey)) + { + _chatConfigService.Save(); + } + + return; + } + + persisted[channelKey] = base64; + _chatConfigService.Save(); + } + + private static string? EncodePersistedMessages(List persistedMessages) + { + if (persistedMessages.Count == 0) + { + return null; + } + + byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(persistedMessages, PersistedHistorySerializerOptions); + return Convert.ToBase64String(jsonBytes); + } + + private static bool TryDecodePersistedHistory(string base64, out List persistedMessages) + { + persistedMessages = new List(); + if (string.IsNullOrWhiteSpace(base64)) + { + return false; + } + + try + { + byte[] jsonBytes = Convert.FromBase64String(base64); + List? decoded = JsonSerializer.Deserialize>(jsonBytes, PersistedHistorySerializerOptions); + if (decoded is null) + { + return false; + } + + persistedMessages = decoded; + return true; + } + catch + { + return false; + } + } + + private static bool TryBuildRestoredMessage(string channelKey, PersistedChatMessage persistedMessage, out ChatMessageEntry restoredMessage) + { + restoredMessage = default; + string messageText = persistedMessage.Message; + DateTime sentAtUtc = persistedMessage.SentAtUtc; + if (string.IsNullOrWhiteSpace(messageText) && persistedMessage.LegacyPayload is { } legacy) + { + messageText = legacy.Message; + sentAtUtc = legacy.SentAtUtc; + } + + if (string.IsNullOrWhiteSpace(messageText)) + { + return false; + } + + ChatChannelDescriptor descriptor = BuildDescriptorFromChannelKey(channelKey); + ChatSenderDescriptor sender = new ChatSenderDescriptor( + ChatSenderKind.Anonymous, + string.Empty, + null, + null, + null, + false); + + ChatMessageDto payload = new ChatMessageDto(descriptor, sender, messageText, sentAtUtc, string.Empty); + restoredMessage = new ChatMessageEntry(payload, persistedMessage.DisplayName, persistedMessage.FromSelf, persistedMessage.ReceivedAtUtc); + return true; + } + + private static ChatChannelDescriptor BuildDescriptorFromChannelKey(string channelKey) + { + if (string.Equals(channelKey, ZoneChannelKey, StringComparison.Ordinal)) + { + return new ChatChannelDescriptor { Type = ChatChannelType.Zone }; + } + + int separatorIndex = channelKey.IndexOf(':', StringComparison.Ordinal); + if (separatorIndex <= 0 || separatorIndex >= channelKey.Length - 1) + { + return new ChatChannelDescriptor { Type = ChatChannelType.Group }; + } + + string typeValue = channelKey[..separatorIndex]; + if (!int.TryParse(typeValue, out int parsedType)) + { + return new ChatChannelDescriptor { Type = ChatChannelType.Group }; + } + + string customKey = channelKey[(separatorIndex + 1)..]; + ChatChannelType channelType = parsedType switch + { + (int)ChatChannelType.Zone => ChatChannelType.Zone, + (int)ChatChannelType.Group => ChatChannelType.Group, + _ => ChatChannelType.Group + }; + + return new ChatChannelDescriptor + { + Type = channelType, + CustomKey = customKey + }; + } + + public void ClearPersistedSyncshellHistory(bool clearLoadedMessages) + { + bool shouldPublish = false; + bool saveConfig = false; + + using (_sync.EnterScope()) + { + Dictionary> cache = _messageHistoryCache; + if (cache.Count > 0) + { + List keysToRemove = new(); + foreach (string key in cache.Keys) + { + if (!string.Equals(key, ZoneChannelKey, StringComparison.Ordinal)) + { + keysToRemove.Add(key); + } + } + + foreach (string key in keysToRemove) + { + cache.Remove(key); + } + + if (keysToRemove.Count > 0) + { + shouldPublish = true; + } + } + + if (clearLoadedMessages) + { + foreach (ChatChannelState state in _channels.Values) + { + if (state.Type != ChatChannelType.Group) + { + continue; + } + + if (state.Messages.Count == 0 && state.UnreadCount == 0 && !state.HasUnread) + { + continue; + } + + state.Messages.Clear(); + state.HasUnread = false; + state.UnreadCount = 0; + _lastReadCounts[state.Key] = 0; + shouldPublish = true; + } + } + + Dictionary persisted = _chatConfigService.Current.SyncshellChannelHistory; + if (persisted.Count > 0) + { + persisted.Clear(); + saveConfig = true; + } + + if (shouldPublish) + { + MarkChannelsSnapshotDirtyLocked(); + } + } + + if (saveConfig) + { + _chatConfigService.Save(); + } + + if (shouldPublish) + { + PublishChannelListChanged(); + } + } + private sealed class ChatChannelState { public ChatChannelState(string key, ChatChannelType type, string displayName, ChatChannelDescriptor descriptor) @@ -1400,4 +1859,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS bool IsOwner); private readonly record struct PendingSelfMessage(string ChannelKey, string Message); + + public sealed record PersistedChatMessage( + string Message = "", + string DisplayName = "", + bool FromSelf = false, + DateTime ReceivedAtUtc = default, + DateTime SentAtUtc = default, + [property: JsonPropertyName("Payload")] ChatMessageDto? LegacyPayload = null); } diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 45fb182..2af13e1 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -424,38 +424,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber if (playerPointer == IntPtr.Zero) return IntPtr.Zero; var playerAddress = playerPointer.Value; - var ownerEntityId = ((Character*)playerAddress)->EntityId; - var candidateAddress = _objectTable.GetObjectAddress(((GameObject*)playerAddress)->ObjectIndex + 1); - if (ownerEntityId == 0) return candidateAddress; - - if (playerAddress == _actorObjectService.LocalPlayerAddress) - { - var localOwned = _actorObjectService.LocalMinionOrMountAddress; - if (localOwned != nint.Zero) - { - return localOwned; - } - } - - if (candidateAddress != nint.Zero) - { - var candidate = (GameObject*)candidateAddress; - var candidateKind = (DalamudObjectKind)candidate->ObjectKind; - if ((candidateKind == DalamudObjectKind.MountType || candidateKind == DalamudObjectKind.Companion) - && ResolveOwnerId(candidate) == ownerEntityId) - { - return candidateAddress; - } - } - - var ownedObject = FindOwnedObject(ownerEntityId, playerAddress, static kind => - kind == DalamudObjectKind.MountType || kind == DalamudObjectKind.Companion); - if (ownedObject != nint.Zero) - { - return ownedObject; - } - - return candidateAddress; + return _objectTable.GetObjectAddress(((GameObject*)playerAddress)->ObjectIndex + 1); } public async Task GetMinionOrMountAsync(IntPtr? playerPointer = null) @@ -485,7 +454,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber } } - return FindOwnedPet(ownerEntityId, ownerAddress); + return IntPtr.Zero; } public async Task GetPetAsync(IntPtr? playerPointer = null) @@ -493,69 +462,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber return await RunOnFrameworkThread(() => GetPetPtr(playerPointer)).ConfigureAwait(false); } - private unsafe nint FindOwnedObject(uint ownerEntityId, nint ownerAddress, Func matchesKind) - { - if (ownerEntityId == 0) - { - return nint.Zero; - } - - foreach (var obj in _objectTable) - { - if (obj is null || obj.Address == nint.Zero || obj.Address == ownerAddress) - { - continue; - } - - if (!matchesKind(obj.ObjectKind)) - { - continue; - } - - var candidate = (GameObject*)obj.Address; - if (ResolveOwnerId(candidate) == ownerEntityId) - { - return obj.Address; - } - } - - return nint.Zero; - } - - private unsafe nint FindOwnedPet(uint ownerEntityId, nint ownerAddress) - { - if (ownerEntityId == 0) - { - return nint.Zero; - } - - foreach (var obj in _objectTable) - { - if (obj is null || obj.Address == nint.Zero || obj.Address == ownerAddress) - { - continue; - } - - if (obj.ObjectKind != DalamudObjectKind.BattleNpc) - { - continue; - } - - var candidate = (GameObject*)obj.Address; - if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet) - { - continue; - } - - if (ResolveOwnerId(candidate) == ownerEntityId) - { - return obj.Address; - } - } - - return nint.Zero; - } - private static unsafe bool IsPetMatch(GameObject* candidate, uint ownerEntityId) { if (candidate == null) diff --git a/LightlessSync/Services/Mediator/Messages.cs b/LightlessSync/Services/Mediator/Messages.cs index e6db9e7..f3cbd75 100644 --- a/LightlessSync/Services/Mediator/Messages.cs +++ b/LightlessSync/Services/Mediator/Messages.cs @@ -21,6 +21,12 @@ public record SwitchToIntroUiMessage : MessageBase; public record SwitchToMainUiMessage : MessageBase; public record OpenSettingsUiMessage : MessageBase; public record OpenLightfinderSettingsMessage : MessageBase; +public enum PerformanceSettingsSection +{ + TextureOptimization, + ModelOptimization, +} +public record OpenPerformanceSettingsMessage(PerformanceSettingsSection Section) : MessageBase; public record DalamudLoginMessage : MessageBase; public record DalamudLogoutMessage : MessageBase; public record ActorTrackedMessage(ActorObjectService.ActorDescriptor Descriptor) : SameThreadMessage; diff --git a/LightlessSync/Services/ModelDecimation/MdlDecimator.cs b/LightlessSync/Services/ModelDecimation/MdlDecimator.cs index a7af13f..c47f3f4 100644 --- a/LightlessSync/Services/ModelDecimation/MdlDecimator.cs +++ b/LightlessSync/Services/ModelDecimation/MdlDecimator.cs @@ -10,7 +10,7 @@ using MdlFile = Penumbra.GameData.Files.MdlFile; using MsLogger = Microsoft.Extensions.Logging.ILogger; namespace LightlessSync.Services.ModelDecimation; - + // if you're coming from another sync service, then kindly fuck off. lightless ftw lil bro internal static class MdlDecimator { private const int MaxStreams = 3; @@ -22,6 +22,7 @@ internal static class MdlDecimator MdlFile.VertexUsage.Position, MdlFile.VertexUsage.Normal, MdlFile.VertexUsage.Tangent1, + MdlFile.VertexUsage.Tangent2, MdlFile.VertexUsage.UV, MdlFile.VertexUsage.Color, MdlFile.VertexUsage.BlendWeights, @@ -30,6 +31,7 @@ internal static class MdlDecimator private static readonly HashSet SupportedTypes = [ + MdlFile.VertexType.Single1, MdlFile.VertexType.Single2, MdlFile.VertexType.Single3, MdlFile.VertexType.Single4, @@ -37,9 +39,15 @@ internal static class MdlDecimator MdlFile.VertexType.Half4, MdlFile.VertexType.UByte4, MdlFile.VertexType.NByte4, + MdlFile.VertexType.Short2, + MdlFile.VertexType.Short4, + MdlFile.VertexType.NShort2, + MdlFile.VertexType.NShort4, + MdlFile.VertexType.UShort2, + MdlFile.VertexType.UShort4, ]; - public static bool TryDecimate(string sourcePath, string destinationPath, int triangleThreshold, double targetRatio, MsLogger logger) + public static bool TryDecimate(string sourcePath, string destinationPath, int triangleThreshold, double targetRatio, bool normalizeTangents, MsLogger logger) { try { @@ -116,7 +124,7 @@ internal static class MdlDecimator bool decimated; if (meshIndex >= lodMeshStart && meshIndex < lodMeshEnd - && TryProcessMesh(mdl, lodIndex, meshIndex, mesh, meshSubMeshes, triangleThreshold, targetRatio, + && TryProcessMesh(mdl, lodIndex, meshIndex, mesh, meshSubMeshes, triangleThreshold, targetRatio, normalizeTangents, out updatedMesh, out updatedSubMeshes, out vertexStreams, @@ -309,6 +317,7 @@ internal static class MdlDecimator MdlStructs.SubmeshStruct[] meshSubMeshes, int triangleThreshold, double targetRatio, + bool normalizeTangents, out MeshStruct updatedMesh, out MdlStructs.SubmeshStruct[] updatedSubMeshes, out byte[][] vertexStreams, @@ -370,7 +379,7 @@ internal static class MdlDecimator return false; } - if (!TryEncodeMeshData(decimatedMesh, format, mesh, meshSubMeshes, out updatedMesh, out updatedSubMeshes, out vertexStreams, out indices, out var encodeReason)) + if (!TryEncodeMeshData(decimatedMesh, format, mesh, meshSubMeshes, normalizeTangents, out updatedMesh, out updatedSubMeshes, out vertexStreams, out indices, out var encodeReason)) { logger.LogDebug("Mesh {MeshIndex} encode failed: {Reason}", meshIndex, encodeReason); return false; @@ -405,11 +414,26 @@ internal static class MdlDecimator mesh.Normals = decoded.Normals; } + if (decoded.PositionWs != null) + { + mesh.PositionWs = decoded.PositionWs; + } + + if (decoded.NormalWs != null) + { + mesh.NormalWs = decoded.NormalWs; + } + if (decoded.Tangents != null) { mesh.Tangents = decoded.Tangents; } + if (decoded.Tangents2 != null) + { + mesh.Tangents2 = decoded.Tangents2; + } + if (decoded.Colors != null) { mesh.Colors = decoded.Colors; @@ -453,9 +477,12 @@ internal static class MdlDecimator var vertexCount = mesh.VertexCount; var positions = new Vector3d[vertexCount]; Vector3[]? normals = format.HasNormals ? new Vector3[vertexCount] : null; - Vector4[]? tangents = format.HasTangents ? new Vector4[vertexCount] : null; + Vector4[]? tangents = format.HasTangent1 ? new Vector4[vertexCount] : null; + Vector4[]? tangents2 = format.HasTangent2 ? new Vector4[vertexCount] : null; Vector4[]? colors = format.HasColors ? new Vector4[vertexCount] : null; BoneWeight[]? boneWeights = format.HasSkinning ? new BoneWeight[vertexCount] : null; + float[]? positionWs = format.HasPositionW ? new float[vertexCount] : null; + float[]? normalWs = format.HasNormalW ? new float[vertexCount] : null; Vector2[][]? uvChannels = null; if (format.UvChannelCount > 0) @@ -477,7 +504,7 @@ internal static class MdlDecimator var uvLookup = format.UvElements.ToDictionary(static element => ElementKey.From(element.Element), static element => element); for (var vertexIndex = 0; vertexIndex < vertexCount; vertexIndex++) { - byte[]? indices = null; + int[]? indices = null; float[]? weights = null; foreach (var element in format.SortedElements) @@ -489,14 +516,31 @@ internal static class MdlDecimator switch (usage) { case MdlFile.VertexUsage.Position: - positions[vertexIndex] = ReadPosition(type, stream); + if (type == MdlFile.VertexType.Single4 && positionWs != null) + { + positions[vertexIndex] = ReadPositionWithW(stream, out positionWs[vertexIndex]); + } + else + { + positions[vertexIndex] = ReadPosition(type, stream); + } break; case MdlFile.VertexUsage.Normal when normals != null: - normals[vertexIndex] = ReadNormal(type, stream); + if (type == MdlFile.VertexType.Single4 && normalWs != null) + { + normals[vertexIndex] = ReadNormalWithW(stream, out normalWs[vertexIndex]); + } + else + { + normals[vertexIndex] = ReadNormal(type, stream); + } break; case MdlFile.VertexUsage.Tangent1 when tangents != null: tangents[vertexIndex] = ReadTangent(type, stream); break; + case MdlFile.VertexUsage.Tangent2 when tangents2 != null: + tangents2[vertexIndex] = ReadTangent(type, stream); + break; case MdlFile.VertexUsage.Color when colors != null: colors[vertexIndex] = ReadColor(type, stream); break; @@ -516,6 +560,7 @@ internal static class MdlDecimator break; default: if (usage == MdlFile.VertexUsage.Normal || usage == MdlFile.VertexUsage.Tangent1 + || usage == MdlFile.VertexUsage.Tangent2 || usage == MdlFile.VertexUsage.Color) { _ = ReadAndDiscard(type, stream); @@ -537,7 +582,7 @@ internal static class MdlDecimator } } - decoded = new DecodedMeshData(positions, normals, tangents, colors, boneWeights, uvChannels); + decoded = new DecodedMeshData(positions, normals, tangents, tangents2, colors, boneWeights, uvChannels, positionWs, normalWs); return true; } @@ -546,6 +591,7 @@ internal static class MdlDecimator VertexFormat format, MeshStruct originalMesh, MdlStructs.SubmeshStruct[] originalSubMeshes, + bool normalizeTangents, out MeshStruct updatedMesh, out MdlStructs.SubmeshStruct[] updatedSubMeshes, out byte[][] vertexStreams, @@ -567,8 +613,11 @@ internal static class MdlDecimator var normals = decimatedMesh.Normals; var tangents = decimatedMesh.Tangents; + var tangents2 = decimatedMesh.Tangents2; var colors = decimatedMesh.Colors; var boneWeights = decimatedMesh.BoneWeights; + var positionWs = decimatedMesh.PositionWs; + var normalWs = decimatedMesh.NormalWs; if (format.HasNormals && normals == null) { @@ -576,12 +625,24 @@ internal static class MdlDecimator return false; } - if (format.HasTangents && tangents == null) + if (format.HasTangent1 && tangents == null) { - reason = "Missing tangents after decimation."; + reason = "Missing tangent1 after decimation."; return false; } + if (format.HasTangent2 && tangents2 == null) + { + reason = "Missing tangent2 after decimation."; + return false; + } + + if (normalizeTangents) + { + NormalizeTangents(tangents, clampW: true); + NormalizeTangents(tangents2, clampW: true); + } + if (format.HasColors && colors == null) { reason = "Missing colors after decimation."; @@ -594,6 +655,18 @@ internal static class MdlDecimator return false; } + if (format.HasPositionW && positionWs == null) + { + reason = "Missing position W after decimation."; + return false; + } + + if (format.HasNormalW && normalWs == null) + { + reason = "Missing normal W after decimation."; + return false; + } + var uvChannels = Array.Empty(); if (format.UvChannelCount > 0) { @@ -659,14 +732,17 @@ internal static class MdlDecimator switch (usage) { case MdlFile.VertexUsage.Position: - WritePosition(type, decimatedMesh.Vertices[vertexIndex], target); + WritePosition(type, decimatedMesh.Vertices[vertexIndex], target, positionWs != null ? positionWs[vertexIndex] : null); break; case MdlFile.VertexUsage.Normal when normals != null: - WriteNormal(type, normals[vertexIndex], target); + WriteNormal(type, normals[vertexIndex], target, normalWs != null ? normalWs[vertexIndex] : null); break; case MdlFile.VertexUsage.Tangent1 when tangents != null: WriteTangent(type, tangents[vertexIndex], target); break; + case MdlFile.VertexUsage.Tangent2 when tangents2 != null: + WriteTangent(type, tangents2[vertexIndex], target); + break; case MdlFile.VertexUsage.Color when colors != null: WriteColor(type, colors[vertexIndex], target); break; @@ -876,26 +952,50 @@ internal static class MdlDecimator if (normalElements.Length == 1) { var normalType = (MdlFile.VertexType)normalElements[0].Type; - if (normalType != MdlFile.VertexType.Single3 && normalType != MdlFile.VertexType.Single4 && normalType != MdlFile.VertexType.NByte4) + if (normalType != MdlFile.VertexType.Single3 + && normalType != MdlFile.VertexType.Single4 + && normalType != MdlFile.VertexType.NByte4 + && normalType != MdlFile.VertexType.NShort4) { reason = "Unsupported normal element type."; return false; } } - var tangentElements = elements.Where(static e => (MdlFile.VertexUsage)e.Usage == MdlFile.VertexUsage.Tangent1).ToArray(); - if (tangentElements.Length > 1) + var tangent1Elements = elements.Where(static e => (MdlFile.VertexUsage)e.Usage == MdlFile.VertexUsage.Tangent1).ToArray(); + if (tangent1Elements.Length > 1) { - reason = "Multiple tangent elements unsupported."; + reason = "Multiple tangent1 elements unsupported."; return false; } - if (tangentElements.Length == 1) + if (tangent1Elements.Length == 1) { - var tangentType = (MdlFile.VertexType)tangentElements[0].Type; - if (tangentType != MdlFile.VertexType.Single4 && tangentType != MdlFile.VertexType.NByte4) + var tangentType = (MdlFile.VertexType)tangent1Elements[0].Type; + if (tangentType != MdlFile.VertexType.Single4 + && tangentType != MdlFile.VertexType.NByte4 + && tangentType != MdlFile.VertexType.NShort4) { - reason = "Unsupported tangent element type."; + reason = "Unsupported tangent1 element type."; + return false; + } + } + + var tangent2Elements = elements.Where(static e => (MdlFile.VertexUsage)e.Usage == MdlFile.VertexUsage.Tangent2).ToArray(); + if (tangent2Elements.Length > 1) + { + reason = "Multiple tangent2 elements unsupported."; + return false; + } + + if (tangent2Elements.Length == 1) + { + var tangentType = (MdlFile.VertexType)tangent2Elements[0].Type; + if (tangentType != MdlFile.VertexType.Single4 + && tangentType != MdlFile.VertexType.NByte4 + && tangentType != MdlFile.VertexType.NShort4) + { + reason = "Unsupported tangent2 element type."; return false; } } @@ -911,7 +1011,12 @@ internal static class MdlDecimator if (colorElements.Length == 1) { var colorType = (MdlFile.VertexType)colorElements[0].Type; - if (colorType != MdlFile.VertexType.UByte4 && colorType != MdlFile.VertexType.NByte4 && colorType != MdlFile.VertexType.Single4) + if (colorType != MdlFile.VertexType.UByte4 + && colorType != MdlFile.VertexType.NByte4 + && colorType != MdlFile.VertexType.Single4 + && colorType != MdlFile.VertexType.Short4 + && colorType != MdlFile.VertexType.NShort4 + && colorType != MdlFile.VertexType.UShort4) { reason = "Unsupported color element type."; return false; @@ -937,14 +1042,18 @@ internal static class MdlDecimator if (blendIndicesElements.Length == 1) { var indexType = (MdlFile.VertexType)blendIndicesElements[0].Type; - if (indexType != MdlFile.VertexType.UByte4) + if (indexType != MdlFile.VertexType.UByte4 && indexType != MdlFile.VertexType.UShort4) { reason = "Unsupported blend index type."; return false; } var weightType = (MdlFile.VertexType)blendWeightsElements[0].Type; - if (weightType != MdlFile.VertexType.UByte4 && weightType != MdlFile.VertexType.NByte4 && weightType != MdlFile.VertexType.Single4) + if (weightType != MdlFile.VertexType.UByte4 + && weightType != MdlFile.VertexType.NByte4 + && weightType != MdlFile.VertexType.Single4 + && weightType != MdlFile.VertexType.UShort4 + && weightType != MdlFile.VertexType.NShort4) { reason = "Unsupported blend weight type."; return false; @@ -956,11 +1065,14 @@ internal static class MdlDecimator return false; } + var positionElement = positionElements[0]; var sortedElements = elements.OrderBy(static element => element.Offset).ToList(); format = new VertexFormat( sortedElements, + positionElement, normalElements.Length == 1 ? normalElements[0] : (MdlStructs.VertexElement?)null, - tangentElements.Length == 1 ? tangentElements[0] : (MdlStructs.VertexElement?)null, + tangent1Elements.Length == 1 ? tangent1Elements[0] : (MdlStructs.VertexElement?)null, + tangent2Elements.Length == 1 ? tangent2Elements[0] : (MdlStructs.VertexElement?)null, colorElement, blendIndicesElements.Length == 1 ? blendIndicesElements[0] : (MdlStructs.VertexElement?)null, blendWeightsElements.Length == 1 ? blendWeightsElements[0] : (MdlStructs.VertexElement?)null, @@ -987,7 +1099,12 @@ internal static class MdlDecimator foreach (var element in uvList) { var type = (MdlFile.VertexType)element.Type; - if (type == MdlFile.VertexType.Half2 || type == MdlFile.VertexType.Single2) + if (type == MdlFile.VertexType.Half2 + || type == MdlFile.VertexType.Single2 + || type == MdlFile.VertexType.Short2 + || type == MdlFile.VertexType.NShort2 + || type == MdlFile.VertexType.UShort2 + || type == MdlFile.VertexType.Single1) { if (uvChannelCount + 1 > Mesh.UVChannelCount) { @@ -998,7 +1115,11 @@ internal static class MdlDecimator uvElements.Add(new UvElementPacking(element, uvChannelCount, null)); uvChannelCount += 1; } - else if (type == MdlFile.VertexType.Half4 || type == MdlFile.VertexType.Single4) + else if (type == MdlFile.VertexType.Half4 + || type == MdlFile.VertexType.Single4 + || type == MdlFile.VertexType.Short4 + || type == MdlFile.VertexType.NShort4 + || type == MdlFile.VertexType.UShort4) { if (uvChannelCount + 2 > Mesh.UVChannelCount) { @@ -1042,6 +1163,15 @@ internal static class MdlDecimator } } + private static Vector3d ReadPositionWithW(BinaryReader reader, out float w) + { + var x = reader.ReadSingle(); + var y = reader.ReadSingle(); + var z = reader.ReadSingle(); + w = reader.ReadSingle(); + return new Vector3d(x, y, z); + } + private static Vector3 ReadNormal(MdlFile.VertexType type, BinaryReader reader) { switch (type) @@ -1056,17 +1186,29 @@ internal static class MdlDecimator return new Vector3(x, y, z); case MdlFile.VertexType.NByte4: return ReadNByte4(reader).ToVector3(); + case MdlFile.VertexType.NShort4: + return ReadNShort4(reader).ToVector3(); default: throw new InvalidOperationException($"Unsupported normal type {type}"); } } + private static Vector3 ReadNormalWithW(BinaryReader reader, out float w) + { + var x = reader.ReadSingle(); + var y = reader.ReadSingle(); + var z = reader.ReadSingle(); + w = reader.ReadSingle(); + return new Vector3(x, y, z); + } + private static Vector4 ReadTangent(MdlFile.VertexType type, BinaryReader reader) { return type switch { MdlFile.VertexType.Single4 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), MdlFile.VertexType.NByte4 => ReadNByte4(reader), + MdlFile.VertexType.NShort4 => ReadNShort4(reader), _ => throw new InvalidOperationException($"Unsupported tangent type {type}"), }; } @@ -1078,27 +1220,79 @@ internal static class MdlDecimator MdlFile.VertexType.UByte4 => ReadUByte4(reader), MdlFile.VertexType.NByte4 => ReadUByte4(reader), MdlFile.VertexType.Single4 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), + MdlFile.VertexType.Short4 => ReadShort4(reader), + MdlFile.VertexType.NShort4 => ReadUShort4Normalized(reader), + MdlFile.VertexType.UShort4 => ReadUShort4Normalized(reader), _ => throw new InvalidOperationException($"Unsupported color type {type}"), }; } + private static void NormalizeTangents(Vector4[]? tangents, bool clampW) + { + if (tangents == null) + { + return; + } + + for (var i = 0; i < tangents.Length; i++) + { + var tangent = tangents[i]; + var length = MathF.Sqrt(tangent.x * tangent.x + tangent.y * tangent.y + tangent.z * tangent.z); + if (length > 1e-6f) + { + tangent.x /= length; + tangent.y /= length; + tangent.z /= length; + } + + if (clampW) + { + tangent.w = tangent.w >= 0f ? 1f : -1f; + } + + tangents[i] = tangent; + } + } + private static void ReadUv(MdlFile.VertexType type, BinaryReader reader, UvElementPacking mapping, Vector2[][] uvChannels, int vertexIndex) { - if (type == MdlFile.VertexType.Half2 || type == MdlFile.VertexType.Single2) + if (type == MdlFile.VertexType.Half2 + || type == MdlFile.VertexType.Single2 + || type == MdlFile.VertexType.Short2 + || type == MdlFile.VertexType.NShort2 + || type == MdlFile.VertexType.UShort2 + || type == MdlFile.VertexType.Single1) { - var uv = type == MdlFile.VertexType.Half2 - ? new Vector2(ReadHalf(reader), ReadHalf(reader)) - : new Vector2(reader.ReadSingle(), reader.ReadSingle()); + var uv = type switch + { + MdlFile.VertexType.Half2 => new Vector2(ReadHalf(reader), ReadHalf(reader)), + MdlFile.VertexType.Single2 => new Vector2(reader.ReadSingle(), reader.ReadSingle()), + MdlFile.VertexType.Short2 => ReadShort2(reader), + MdlFile.VertexType.NShort2 => ReadUShort2Normalized(reader), + MdlFile.VertexType.UShort2 => ReadUShort2Normalized(reader), + MdlFile.VertexType.Single1 => new Vector2(reader.ReadSingle(), 0f), + _ => Vector2.zero, + }; uvChannels[mapping.FirstChannel][vertexIndex] = uv; return; } - if (type == MdlFile.VertexType.Half4 || type == MdlFile.VertexType.Single4) + if (type == MdlFile.VertexType.Half4 + || type == MdlFile.VertexType.Single4 + || type == MdlFile.VertexType.Short4 + || type == MdlFile.VertexType.NShort4 + || type == MdlFile.VertexType.UShort4) { - var uv = type == MdlFile.VertexType.Half4 - ? new Vector4(ReadHalf(reader), ReadHalf(reader), ReadHalf(reader), ReadHalf(reader)) - : new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); + var uv = type switch + { + MdlFile.VertexType.Half4 => new Vector4(ReadHalf(reader), ReadHalf(reader), ReadHalf(reader), ReadHalf(reader)), + MdlFile.VertexType.Single4 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), + MdlFile.VertexType.Short4 => ReadShort4(reader), + MdlFile.VertexType.NShort4 => ReadUShort4Normalized(reader), + MdlFile.VertexType.UShort4 => ReadUShort4Normalized(reader), + _ => Vector4.zero, + }; uvChannels[mapping.FirstChannel][vertexIndex] = new Vector2(uv.x, uv.y); if (mapping.SecondChannel.HasValue) @@ -1108,11 +1302,12 @@ internal static class MdlDecimator } } - private static byte[] ReadIndices(MdlFile.VertexType type, BinaryReader reader) + private static int[] ReadIndices(MdlFile.VertexType type, BinaryReader reader) { return type switch { - MdlFile.VertexType.UByte4 => new[] { reader.ReadByte(), reader.ReadByte(), reader.ReadByte(), reader.ReadByte() }, + MdlFile.VertexType.UByte4 => new[] { (int)reader.ReadByte(), (int)reader.ReadByte(), (int)reader.ReadByte(), (int)reader.ReadByte() }, + MdlFile.VertexType.UShort4 => new[] { (int)reader.ReadUInt16(), (int)reader.ReadUInt16(), (int)reader.ReadUInt16(), (int)reader.ReadUInt16() }, _ => throw new InvalidOperationException($"Unsupported indices type {type}"), }; } @@ -1124,6 +1319,8 @@ internal static class MdlDecimator MdlFile.VertexType.UByte4 => ReadUByte4(reader).ToFloatArray(), MdlFile.VertexType.NByte4 => ReadUByte4(reader).ToFloatArray(), MdlFile.VertexType.Single4 => new[] { reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle() }, + MdlFile.VertexType.NShort4 => ReadUShort4Normalized(reader).ToFloatArray(), + MdlFile.VertexType.UShort4 => ReadUShort4Normalized(reader).ToFloatArray(), _ => throw new InvalidOperationException($"Unsupported weights type {type}"), }; } @@ -1143,29 +1340,98 @@ internal static class MdlDecimator return (value * 2f) - new Vector4(1f, 1f, 1f, 1f); } - private static Vector4 ReadAndDiscard(MdlFile.VertexType type, BinaryReader reader) + private static Vector2 ReadShort2(BinaryReader reader) + => new(reader.ReadInt16(), reader.ReadInt16()); + + private static Vector4 ReadShort4(BinaryReader reader) + => new(reader.ReadInt16(), reader.ReadInt16(), reader.ReadInt16(), reader.ReadInt16()); + + /* these really don't have a use currently, we don't need to read raw unnormalized ushorts :3 + private static Vector2 ReadUShort2(BinaryReader reader) + => new(reader.ReadUInt16(), reader.ReadUInt16()); + + private static Vector4 ReadUShort4(BinaryReader reader) + => new(reader.ReadUInt16(), reader.ReadUInt16(), reader.ReadUInt16(), reader.ReadUInt16()); + */ + + private static Vector2 ReadUShort2Normalized(BinaryReader reader) + => new(reader.ReadUInt16() / (float)ushort.MaxValue, reader.ReadUInt16() / (float)ushort.MaxValue); + + private static Vector4 ReadUShort4Normalized(BinaryReader reader) + => new(reader.ReadUInt16() / (float)ushort.MaxValue, reader.ReadUInt16() / (float)ushort.MaxValue, reader.ReadUInt16() / (float)ushort.MaxValue, reader.ReadUInt16() / (float)ushort.MaxValue); + + private static Vector4 ReadNShort4(BinaryReader reader) { - return type switch - { - MdlFile.VertexType.Single2 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), 0, 0), - MdlFile.VertexType.Single3 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), 0), - MdlFile.VertexType.Single4 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), - MdlFile.VertexType.Half2 => new Vector4(ReadHalf(reader), ReadHalf(reader), 0, 0), - MdlFile.VertexType.Half4 => new Vector4(ReadHalf(reader), ReadHalf(reader), ReadHalf(reader), ReadHalf(reader)), - MdlFile.VertexType.UByte4 => ReadUByte4(reader), - MdlFile.VertexType.NByte4 => ReadUByte4(reader), - _ => Vector4.zero, - }; + var value = ReadUShort4Normalized(reader); + return (value * 2f) - new Vector4(1f, 1f, 1f, 1f); } - private static void WritePosition(MdlFile.VertexType type, Vector3d value, Span target) + private static Vector4 ReadAndDiscard(MdlFile.VertexType type, BinaryReader reader) { + switch (type) + { + case MdlFile.VertexType.Single1: + return new Vector4(reader.ReadSingle(), 0, 0, 0); + case MdlFile.VertexType.Single2: + return new Vector4(reader.ReadSingle(), reader.ReadSingle(), 0, 0); + case MdlFile.VertexType.Single3: + return new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), 0); + case MdlFile.VertexType.Single4: + return new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); + case MdlFile.VertexType.Half2: + return new Vector4(ReadHalf(reader), ReadHalf(reader), 0, 0); + case MdlFile.VertexType.Half4: + return new Vector4(ReadHalf(reader), ReadHalf(reader), ReadHalf(reader), ReadHalf(reader)); + case MdlFile.VertexType.UByte4: + return ReadUByte4(reader); + case MdlFile.VertexType.NByte4: + return ReadUByte4(reader); + case MdlFile.VertexType.Short2: + { + var value = ReadShort2(reader); + return new Vector4(value.x, value.y, 0, 0); + } + case MdlFile.VertexType.Short4: + return ReadShort4(reader); + case MdlFile.VertexType.NShort2: + { + var value = ReadUShort2Normalized(reader); + return new Vector4(value.x, value.y, 0, 0); + } + case MdlFile.VertexType.NShort4: + return ReadUShort4Normalized(reader); + case MdlFile.VertexType.UShort2: + { + var value = ReadUShort2Normalized(reader); + return new Vector4(value.x, value.y, 0, 0); + } + case MdlFile.VertexType.UShort4: + return ReadUShort4Normalized(reader); + default: + return Vector4.zero; + } + } + + private static void WritePosition(MdlFile.VertexType type, Vector3d value, Span target, float? wOverride = null) + { + if (type == MdlFile.VertexType.Single4 && wOverride.HasValue) + { + WriteVector4(type, new Vector4((float)value.x, (float)value.y, (float)value.z, wOverride.Value), target); + return; + } + WriteVector3(type, new Vector3((float)value.x, (float)value.y, (float)value.z), target); } - private static void WriteNormal(MdlFile.VertexType type, Vector3 value, Span target) + private static void WriteNormal(MdlFile.VertexType type, Vector3 value, Span target, float? wOverride = null) { - WriteVector3(type, value, target, normalized: type == MdlFile.VertexType.NByte4); + if (type == MdlFile.VertexType.Single4 && wOverride.HasValue) + { + WriteVector4(type, new Vector4(value.x, value.y, value.z, wOverride.Value), target); + return; + } + + WriteVector3(type, value, target, normalized: type == MdlFile.VertexType.NByte4 || type == MdlFile.VertexType.NShort4); } private static void WriteTangent(MdlFile.VertexType type, Vector4 value, Span target) @@ -1176,12 +1442,21 @@ internal static class MdlDecimator return; } + if (type == MdlFile.VertexType.NShort4) + { + WriteNShort4(value, target); + return; + } + WriteVector4(type, value, target); } private static void WriteColor(MdlFile.VertexType type, Vector4 value, Span target) { - if (type == MdlFile.VertexType.Single4) + if (type == MdlFile.VertexType.Single4 + || type == MdlFile.VertexType.Short4 + || type == MdlFile.VertexType.NShort4 + || type == MdlFile.VertexType.UShort4) { WriteVector4(type, value, target); return; @@ -1192,28 +1467,40 @@ internal static class MdlDecimator private static void WriteBlendIndices(MdlFile.VertexType type, BoneWeight weights, Span target) { - if (type != MdlFile.VertexType.UByte4) + if (type == MdlFile.VertexType.UByte4) { + target[0] = (byte)Math.Clamp(weights.boneIndex0, 0, 255); + target[1] = (byte)Math.Clamp(weights.boneIndex1, 0, 255); + target[2] = (byte)Math.Clamp(weights.boneIndex2, 0, 255); + target[3] = (byte)Math.Clamp(weights.boneIndex3, 0, 255); return; } - target[0] = (byte)Math.Clamp(weights.boneIndex0, 0, 255); - target[1] = (byte)Math.Clamp(weights.boneIndex1, 0, 255); - target[2] = (byte)Math.Clamp(weights.boneIndex2, 0, 255); - target[3] = (byte)Math.Clamp(weights.boneIndex3, 0, 255); + if (type == MdlFile.VertexType.UShort4) + { + BinaryPrimitives.WriteUInt16LittleEndian(target[..2], ToUShort(weights.boneIndex0)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(2, 2), ToUShort(weights.boneIndex1)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(4, 2), ToUShort(weights.boneIndex2)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(6, 2), ToUShort(weights.boneIndex3)); + } } private static void WriteBlendWeights(MdlFile.VertexType type, BoneWeight weights, Span target) { - if (type != MdlFile.VertexType.UByte4 && type != MdlFile.VertexType.NByte4) + if (type == MdlFile.VertexType.Single4) + { + BinaryPrimitives.WriteSingleLittleEndian(target[..4], weights.boneWeight0); + BinaryPrimitives.WriteSingleLittleEndian(target.Slice(4, 4), weights.boneWeight1); + BinaryPrimitives.WriteSingleLittleEndian(target.Slice(8, 4), weights.boneWeight2); + BinaryPrimitives.WriteSingleLittleEndian(target.Slice(12, 4), weights.boneWeight3); + return; + } + + if (type != MdlFile.VertexType.UByte4 + && type != MdlFile.VertexType.NByte4 + && type != MdlFile.VertexType.UShort4 + && type != MdlFile.VertexType.NShort4) { - if (type == MdlFile.VertexType.Single4) - { - BinaryPrimitives.WriteSingleLittleEndian(target[..4], weights.boneWeight0); - BinaryPrimitives.WriteSingleLittleEndian(target.Slice(4, 4), weights.boneWeight1); - BinaryPrimitives.WriteSingleLittleEndian(target.Slice(8, 4), weights.boneWeight2); - BinaryPrimitives.WriteSingleLittleEndian(target.Slice(12, 4), weights.boneWeight3); - } return; } @@ -1223,6 +1510,15 @@ internal static class MdlDecimator var w3 = Clamp01(weights.boneWeight3); NormalizeWeights(ref w0, ref w1, ref w2, ref w3); + if (type == MdlFile.VertexType.UShort4 || type == MdlFile.VertexType.NShort4) + { + BinaryPrimitives.WriteUInt16LittleEndian(target[..2], ToUShortNormalized(w0)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(2, 2), ToUShortNormalized(w1)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(4, 2), ToUShortNormalized(w2)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(6, 2), ToUShortNormalized(w3)); + return; + } + target[0] = ToByte(w0); target[1] = ToByte(w1); target[2] = ToByte(w2); @@ -1231,14 +1527,23 @@ internal static class MdlDecimator private static void WriteUv(MdlFile.VertexType type, UvElementPacking mapping, Vector2[][] uvChannels, int vertexIndex, Span target) { - if (type == MdlFile.VertexType.Half2 || type == MdlFile.VertexType.Single2) + if (type == MdlFile.VertexType.Half2 + || type == MdlFile.VertexType.Single2 + || type == MdlFile.VertexType.Short2 + || type == MdlFile.VertexType.NShort2 + || type == MdlFile.VertexType.UShort2 + || type == MdlFile.VertexType.Single1) { var uv = uvChannels[mapping.FirstChannel][vertexIndex]; WriteVector2(type, uv, target); return; } - if (type == MdlFile.VertexType.Half4 || type == MdlFile.VertexType.Single4) + if (type == MdlFile.VertexType.Half4 + || type == MdlFile.VertexType.Single4 + || type == MdlFile.VertexType.Short4 + || type == MdlFile.VertexType.NShort4 + || type == MdlFile.VertexType.UShort4) { var uv0 = uvChannels[mapping.FirstChannel][vertexIndex]; var uv1 = mapping.SecondChannel.HasValue @@ -1250,6 +1555,12 @@ internal static class MdlDecimator private static void WriteVector2(MdlFile.VertexType type, Vector2 value, Span target) { + if (type == MdlFile.VertexType.Single1) + { + BinaryPrimitives.WriteSingleLittleEndian(target[..4], value.x); + return; + } + if (type == MdlFile.VertexType.Single2) { BinaryPrimitives.WriteSingleLittleEndian(target[..4], value.x); @@ -1261,6 +1572,24 @@ internal static class MdlDecimator { WriteHalf(target[..2], value.x); WriteHalf(target.Slice(2, 2), value.y); + return; + } + + if (type == MdlFile.VertexType.Short2) + { + WriteShort2(value, target); + return; + } + + if (type == MdlFile.VertexType.NShort2) + { + WriteUShort2Normalized(value, target); + return; + } + + if (type == MdlFile.VertexType.UShort2) + { + WriteUShort2Normalized(value, target); } } @@ -1286,6 +1615,12 @@ internal static class MdlDecimator if (type == MdlFile.VertexType.NByte4 && normalized) { WriteNByte4(new Vector4(value.x, value.y, value.z, 0f), target); + return; + } + + if (type == MdlFile.VertexType.NShort4 && normalized) + { + WriteNShort4(new Vector4(value.x, value.y, value.z, 0f), target); } } @@ -1308,6 +1643,23 @@ internal static class MdlDecimator WriteHalf(target.Slice(6, 2), value.w); return; } + + if (type == MdlFile.VertexType.Short4) + { + WriteShort4(value, target); + return; + } + + if (type == MdlFile.VertexType.NShort4) + { + WriteUShort4Normalized(value, target); + return; + } + + if (type == MdlFile.VertexType.UShort4) + { + WriteUShort4Normalized(value, target); + } } private static void WriteUByte4(Vector4 value, Span target) @@ -1324,6 +1676,58 @@ internal static class MdlDecimator WriteUByte4(normalized, target); } + private static void WriteShort2(Vector2 value, Span target) + { + BinaryPrimitives.WriteInt16LittleEndian(target[..2], ToShort(value.x)); + BinaryPrimitives.WriteInt16LittleEndian(target.Slice(2, 2), ToShort(value.y)); + } + + private static void WriteShort4(Vector4 value, Span target) + { + BinaryPrimitives.WriteInt16LittleEndian(target[..2], ToShort(value.x)); + BinaryPrimitives.WriteInt16LittleEndian(target.Slice(2, 2), ToShort(value.y)); + BinaryPrimitives.WriteInt16LittleEndian(target.Slice(4, 2), ToShort(value.z)); + BinaryPrimitives.WriteInt16LittleEndian(target.Slice(6, 2), ToShort(value.w)); + } + + /* same thing as read here, we don't need to write currently either + private static void WriteUShort2(Vector2 value, Span target) + { + BinaryPrimitives.WriteUInt16LittleEndian(target[..2], ToUShort(value.x)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(2, 2), ToUShort(value.y)); + } + + private static void WriteUShort4(Vector4 value, Span target) + { + BinaryPrimitives.WriteUInt16LittleEndian(target[..2], ToUShort(value.x)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(2, 2), ToUShort(value.y)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(4, 2), ToUShort(value.z)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(6, 2), ToUShort(value.w)); + } + */ + + private static void WriteUShort2Normalized(Vector2 value, Span target) + { + BinaryPrimitives.WriteUInt16LittleEndian(target[..2], ToUShortNormalized(value.x)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(2, 2), ToUShortNormalized(value.y)); + } + + private static void WriteUShort4Normalized(Vector4 value, Span target) + { + BinaryPrimitives.WriteUInt16LittleEndian(target[..2], ToUShortNormalized(value.x)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(2, 2), ToUShortNormalized(value.y)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(4, 2), ToUShortNormalized(value.z)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(6, 2), ToUShortNormalized(value.w)); + } + + private static void WriteNShort4(Vector4 value, Span target) + { + BinaryPrimitives.WriteUInt16LittleEndian(target[..2], ToUShortSnorm(value.x)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(2, 2), ToUShortSnorm(value.y)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(4, 2), ToUShortSnorm(value.z)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(6, 2), ToUShortSnorm(value.w)); + } + private static void WriteHalf(Span target, float value) { var half = (Half)value; @@ -1336,9 +1740,32 @@ internal static class MdlDecimator private static float Clamp01(float value) => Math.Clamp(value, 0f, 1f); + private static float ClampMinusOneToOne(float value) + => Math.Clamp(value, -1f, 1f); + private static byte ToByte(float value) => (byte)Math.Clamp((int)Math.Round(value * 255f), 0, 255); + private static short ToShort(float value) + => (short)Math.Clamp((int)Math.Round(value), short.MinValue, short.MaxValue); + + private static ushort ToUShort(int value) + => (ushort)Math.Clamp(value, ushort.MinValue, ushort.MaxValue); + + /* + private static ushort ToUShort(float value) + => (ushort)Math.Clamp((int)Math.Round(value), ushort.MinValue, ushort.MaxValue); + */ + + private static ushort ToUShortNormalized(float value) + => (ushort)Math.Clamp((int)Math.Round(Clamp01(value) * ushort.MaxValue), ushort.MinValue, ushort.MaxValue); + + private static ushort ToUShortSnorm(float value) + { + var normalized = (ClampMinusOneToOne(value) * 0.5f) + 0.5f; + return ToUShortNormalized(normalized); + } + private static void NormalizeWeights(float[] weights) { var sum = weights.Sum(); @@ -1370,6 +1797,7 @@ internal static class MdlDecimator private static int GetElementSize(MdlFile.VertexType type) => type switch { + MdlFile.VertexType.Single1 => 4, MdlFile.VertexType.Single2 => 8, MdlFile.VertexType.Single3 => 12, MdlFile.VertexType.Single4 => 16, @@ -1377,6 +1805,12 @@ internal static class MdlDecimator MdlFile.VertexType.Half4 => 8, MdlFile.VertexType.UByte4 => 4, MdlFile.VertexType.NByte4 => 4, + MdlFile.VertexType.Short2 => 4, + MdlFile.VertexType.Short4 => 8, + MdlFile.VertexType.NShort2 => 4, + MdlFile.VertexType.NShort4 => 8, + MdlFile.VertexType.UShort2 => 4, + MdlFile.VertexType.UShort4 => 8, _ => throw new InvalidOperationException($"Unsupported vertex type {type}"), }; @@ -1390,8 +1824,10 @@ internal static class MdlDecimator { public VertexFormat( List sortedElements, + MdlStructs.VertexElement positionElement, MdlStructs.VertexElement? normalElement, - MdlStructs.VertexElement? tangentElement, + MdlStructs.VertexElement? tangent1Element, + MdlStructs.VertexElement? tangent2Element, MdlStructs.VertexElement? colorElement, MdlStructs.VertexElement? blendIndicesElement, MdlStructs.VertexElement? blendWeightsElement, @@ -1399,8 +1835,10 @@ internal static class MdlDecimator int uvChannelCount) { SortedElements = sortedElements; + PositionElement = positionElement; NormalElement = normalElement; - TangentElement = tangentElement; + Tangent1Element = tangent1Element; + Tangent2Element = tangent2Element; ColorElement = colorElement; BlendIndicesElement = blendIndicesElement; BlendWeightsElement = blendWeightsElement; @@ -1409,8 +1847,10 @@ internal static class MdlDecimator } public List SortedElements { get; } + public MdlStructs.VertexElement PositionElement { get; } public MdlStructs.VertexElement? NormalElement { get; } - public MdlStructs.VertexElement? TangentElement { get; } + public MdlStructs.VertexElement? Tangent1Element { get; } + public MdlStructs.VertexElement? Tangent2Element { get; } public MdlStructs.VertexElement? ColorElement { get; } public MdlStructs.VertexElement? BlendIndicesElement { get; } public MdlStructs.VertexElement? BlendWeightsElement { get; } @@ -1418,9 +1858,12 @@ internal static class MdlDecimator public int UvChannelCount { get; } public bool HasNormals => NormalElement.HasValue; - public bool HasTangents => TangentElement.HasValue; + public bool HasTangent1 => Tangent1Element.HasValue; + public bool HasTangent2 => Tangent2Element.HasValue; public bool HasColors => ColorElement.HasValue; public bool HasSkinning => BlendIndicesElement.HasValue && BlendWeightsElement.HasValue; + public bool HasPositionW => (MdlFile.VertexType)PositionElement.Type == MdlFile.VertexType.Single4; + public bool HasNormalW => NormalElement.HasValue && (MdlFile.VertexType)NormalElement.Value.Type == MdlFile.VertexType.Single4; } private readonly record struct UvElementPacking(MdlStructs.VertexElement Element, int FirstChannel, int? SecondChannel); @@ -1431,24 +1874,33 @@ internal static class MdlDecimator Vector3d[] positions, Vector3[]? normals, Vector4[]? tangents, + Vector4[]? tangents2, Vector4[]? colors, BoneWeight[]? boneWeights, - Vector2[][]? uvChannels) + Vector2[][]? uvChannels, + float[]? positionWs, + float[]? normalWs) { Positions = positions; Normals = normals; Tangents = tangents; + Tangents2 = tangents2; Colors = colors; BoneWeights = boneWeights; UvChannels = uvChannels; + PositionWs = positionWs; + NormalWs = normalWs; } public Vector3d[] Positions { get; } public Vector3[]? Normals { get; } public Vector4[]? Tangents { get; } + public Vector4[]? Tangents2 { get; } public Vector4[]? Colors { get; } public BoneWeight[]? BoneWeights { get; } public Vector2[][]? UvChannels { get; } + public float[]? PositionWs { get; } + public float[]? NormalWs { get; } } } diff --git a/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs b/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs index f666805..98f1f88 100644 --- a/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs +++ b/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs @@ -1,5 +1,6 @@ using LightlessSync.FileCache; using LightlessSync.LightlessConfiguration; +using LightlessSync.Utils; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Globalization; @@ -19,7 +20,7 @@ public sealed class ModelDecimationService private readonly XivDataStorageService _xivDataStorageService; private readonly SemaphoreSlim _decimationSemaphore = new(MaxConcurrentJobs); - private readonly ConcurrentDictionary _activeJobs = new(StringComparer.OrdinalIgnoreCase); + private readonly TaskRegistry _decimationDeduplicator = new(); private readonly ConcurrentDictionary _decimatedPaths = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _failedHashes = new(StringComparer.OrdinalIgnoreCase); @@ -44,14 +45,14 @@ public sealed class ModelDecimationService return; } - if (_decimatedPaths.ContainsKey(hash) || _failedHashes.ContainsKey(hash) || _activeJobs.ContainsKey(hash)) + if (_decimatedPaths.ContainsKey(hash) || _failedHashes.ContainsKey(hash) || _decimationDeduplicator.TryGetExisting(hash, out _)) { return; } _logger.LogInformation("Queued model decimation for {Hash}", hash); - _activeJobs[hash] = Task.Run(async () => + _decimationDeduplicator.GetOrStart(hash, async () => { await _decimationSemaphore.WaitAsync().ConfigureAwait(false); try @@ -66,9 +67,8 @@ public sealed class ModelDecimationService finally { _decimationSemaphore.Release(); - _activeJobs.TryRemove(hash, out _); } - }, CancellationToken.None); + }); } public bool ShouldScheduleDecimation(string hash, string filePath, string? gamePath = null) @@ -116,7 +116,7 @@ public sealed class ModelDecimationService continue; } - if (_activeJobs.TryGetValue(hash, out var job)) + if (_decimationDeduplicator.TryGetExisting(hash, out var job)) { pending.Add(job); } @@ -139,13 +139,18 @@ public sealed class ModelDecimationService return Task.CompletedTask; } - if (!TryGetDecimationSettings(out var triangleThreshold, out var targetRatio)) + if (!TryGetDecimationSettings(out var triangleThreshold, out var targetRatio, out var normalizeTangents)) { _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); + _logger.LogInformation( + "Starting model decimation for {Hash} (threshold {Threshold}, ratio {Ratio:0.##}, normalize tangents {NormalizeTangents})", + hash, + triangleThreshold, + targetRatio, + normalizeTangents); var destination = Path.Combine(GetDecimatedDirectory(), $"{hash}.mdl"); if (File.Exists(destination)) @@ -154,7 +159,7 @@ public sealed class ModelDecimationService return Task.CompletedTask; } - if (!MdlDecimator.TryDecimate(sourcePath, destination, triangleThreshold, targetRatio, _logger)) + if (!MdlDecimator.TryDecimate(sourcePath, destination, triangleThreshold, targetRatio, normalizeTangents, _logger)) { _failedHashes[hash] = 1; _logger.LogInformation("Model decimation skipped for {Hash}", hash); @@ -313,10 +318,11 @@ public sealed class ModelDecimationService private static string NormalizeGamePath(string path) => path.Replace('\\', '/').ToLowerInvariant(); - private bool TryGetDecimationSettings(out int triangleThreshold, out double targetRatio) + private bool TryGetDecimationSettings(out int triangleThreshold, out double targetRatio, out bool normalizeTangents) { triangleThreshold = 15_000; targetRatio = 0.8; + normalizeTangents = true; var config = _performanceConfigService.Current; if (!config.EnableModelDecimation) @@ -326,6 +332,7 @@ public sealed class ModelDecimationService triangleThreshold = Math.Max(0, config.ModelDecimationTriangleThreshold); targetRatio = config.ModelDecimationTargetRatio; + normalizeTangents = config.ModelDecimationNormalizeTangents; if (double.IsNaN(targetRatio) || double.IsInfinity(targetRatio)) { return false; diff --git a/LightlessSync/Services/TextureCompression/TextureCompressionService.cs b/LightlessSync/Services/TextureCompression/TextureCompressionService.cs index c31539f..81b3c52 100644 --- a/LightlessSync/Services/TextureCompression/TextureCompressionService.cs +++ b/LightlessSync/Services/TextureCompression/TextureCompressionService.cs @@ -2,6 +2,7 @@ using LightlessSync.Interop.Ipc; using LightlessSync.FileCache; using Microsoft.Extensions.Logging; using Penumbra.Api.Enums; +using System.Globalization; namespace LightlessSync.Services.TextureCompression; @@ -27,7 +28,9 @@ public sealed class TextureCompressionService public async Task ConvertTexturesAsync( IReadOnlyList requests, IProgress? progress, - CancellationToken token) + CancellationToken token, + bool requestRedraw = true, + bool includeMipMaps = true) { if (requests.Count == 0) { @@ -48,7 +51,7 @@ public sealed class TextureCompressionService continue; } - await RunPenumbraConversionAsync(request, textureType, total, completed, progress, token).ConfigureAwait(false); + await RunPenumbraConversionAsync(request, textureType, total, completed, progress, token, requestRedraw, includeMipMaps).ConfigureAwait(false); completed++; } @@ -65,14 +68,16 @@ public sealed class TextureCompressionService int total, int completedBefore, IProgress? progress, - CancellationToken token) + CancellationToken token, + bool requestRedraw, + bool includeMipMaps) { var primaryPath = request.PrimaryFilePath; var displayJob = new TextureConversionJob( primaryPath, primaryPath, targetType, - IncludeMipMaps: true, + IncludeMipMaps: includeMipMaps, request.DuplicateFilePaths); var backupPath = CreateBackupCopy(primaryPath); @@ -83,7 +88,7 @@ public sealed class TextureCompressionService try { WaitForAccess(primaryPath); - await _ipcManager.Penumbra.ConvertTextureFiles(_logger, new[] { conversionJob }, null, token).ConfigureAwait(false); + await _ipcManager.Penumbra.ConvertTextureFiles(_logger, new[] { conversionJob }, null, token, requestRedraw).ConfigureAwait(false); if (!IsValidConversionResult(displayJob.OutputFile)) { @@ -128,19 +133,46 @@ public sealed class TextureCompressionService var cacheEntries = _fileCacheManager.GetFileCachesByPaths(paths.ToArray()); foreach (var path in paths) { + var hasExpectedHash = TryGetExpectedHashFromPath(path, out var expectedHash); if (!cacheEntries.TryGetValue(path, out var entry) || entry is null) { - entry = _fileCacheManager.CreateFileEntry(path); + if (hasExpectedHash) + { + entry = _fileCacheManager.CreateCacheEntryWithKnownHash(path, expectedHash); + } + + entry ??= _fileCacheManager.CreateFileEntry(path); if (entry is null) { _logger.LogWarning("Unable to locate cache entry for {Path}; skipping hash refresh", path); continue; } } + else if (hasExpectedHash && entry.IsCacheEntry && !string.Equals(entry.Hash, expectedHash, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Fixing cache hash mismatch for {Path}: {Current} -> {Expected}", path, entry.Hash, expectedHash); + _fileCacheManager.RemoveHashedFile(entry.Hash, entry.PrefixedFilePath, removeDerivedFiles: false); + var corrected = _fileCacheManager.CreateCacheEntryWithKnownHash(path, expectedHash); + if (corrected is not null) + { + entry = corrected; + } + } try { - _fileCacheManager.UpdateHashedFile(entry); + if (entry.IsCacheEntry) + { + var info = new FileInfo(path); + entry.Size = info.Length; + entry.CompressedSize = null; + entry.LastModifiedDateTicks = info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture); + _fileCacheManager.UpdateHashedFile(entry, computeProperties: false); + } + else + { + _fileCacheManager.UpdateHashedFile(entry); + } } catch (Exception ex) { @@ -149,6 +181,35 @@ public sealed class TextureCompressionService } } + private static bool TryGetExpectedHashFromPath(string path, out string hash) + { + hash = Path.GetFileNameWithoutExtension(path); + if (string.IsNullOrWhiteSpace(hash)) + { + return false; + } + + if (hash.Length is not (40 or 64)) + { + return false; + } + + for (var i = 0; i < hash.Length; i++) + { + var c = hash[i]; + var isHex = (c >= '0' && c <= '9') + || (c >= 'a' && c <= 'f') + || (c >= 'A' && c <= 'F'); + if (!isHex) + { + return false; + } + } + + hash = hash.ToUpperInvariant(); + return true; + } + private static readonly string WorkingDirectory = Path.Combine(Path.GetTempPath(), "LightlessSync.TextureCompression"); diff --git a/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs b/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs index 6fa6f92..b5d677c 100644 --- a/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs +++ b/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs @@ -4,9 +4,11 @@ using System.Buffers.Binary; using System.Globalization; using System.IO; using System.Runtime.InteropServices; +using System.Threading; using OtterTex; using OtterImage = OtterTex.Image; using LightlessSync.LightlessConfiguration; +using LightlessSync.Utils; using LightlessSync.FileCache; using Microsoft.Extensions.Logging; using Lumina.Data.Files; @@ -30,10 +32,12 @@ public sealed class TextureDownscaleService private readonly LightlessConfigService _configService; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; private readonly FileCacheManager _fileCacheManager; + private readonly TextureCompressionService _textureCompressionService; - private readonly ConcurrentDictionary _activeJobs = new(StringComparer.OrdinalIgnoreCase); + private readonly TaskRegistry _downscaleDeduplicator = new(); private readonly ConcurrentDictionary _downscaledPaths = new(StringComparer.OrdinalIgnoreCase); private readonly SemaphoreSlim _downscaleSemaphore = new(4); + private readonly SemaphoreSlim _compressionSemaphore = new(1); private static readonly IReadOnlyDictionary BlockCompressedFormatMap = new Dictionary { @@ -68,12 +72,14 @@ public sealed class TextureDownscaleService ILogger logger, LightlessConfigService configService, PlayerPerformanceConfigService playerPerformanceConfigService, - FileCacheManager fileCacheManager) + FileCacheManager fileCacheManager, + TextureCompressionService textureCompressionService) { _logger = logger; _configService = configService; _playerPerformanceConfigService = playerPerformanceConfigService; _fileCacheManager = fileCacheManager; + _textureCompressionService = textureCompressionService; } public void ScheduleDownscale(string hash, string filePath, TextureMapKind mapKind) @@ -82,9 +88,9 @@ public sealed class TextureDownscaleService public void ScheduleDownscale(string hash, string filePath, Func mapKindFactory) { if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)) return; - if (_activeJobs.ContainsKey(hash)) return; + if (_downscaleDeduplicator.TryGetExisting(hash, out _)) return; - _activeJobs[hash] = Task.Run(async () => + _downscaleDeduplicator.GetOrStart(hash, async () => { TextureMapKind mapKind; try @@ -98,7 +104,7 @@ public sealed class TextureDownscaleService } await DownscaleInternalAsync(hash, filePath, mapKind).ConfigureAwait(false); - }, CancellationToken.None); + }); } public bool ShouldScheduleDownscale(string filePath) @@ -107,7 +113,9 @@ public sealed class TextureDownscaleService return false; var performanceConfig = _playerPerformanceConfigService.Current; - return performanceConfig.EnableNonIndexTextureMipTrim || performanceConfig.EnableIndexTextureDownscale; + return performanceConfig.EnableNonIndexTextureMipTrim + || performanceConfig.EnableIndexTextureDownscale + || performanceConfig.EnableUncompressedTextureCompression; } public string GetPreferredPath(string hash, string originalPath) @@ -144,7 +152,7 @@ public sealed class TextureDownscaleService continue; } - if (_activeJobs.TryGetValue(hash, out var job)) + if (_downscaleDeduplicator.TryGetExisting(hash, out var job)) { pending.Add(job); } @@ -182,10 +190,18 @@ public sealed class TextureDownscaleService targetMaxDimension = ResolveTargetMaxDimension(); onlyDownscaleUncompressed = performanceConfig.OnlyDownscaleUncompressedTextures; + if (onlyDownscaleUncompressed && !headerInfo.HasValue) + { + _downscaledPaths[hash] = sourcePath; + _logger.LogTrace("Skipping downscale for texture {Hash}; format unknown and only-uncompressed enabled.", hash); + return; + } + destination = Path.Combine(GetDownscaledDirectory(), $"{hash}.tex"); if (File.Exists(destination)) { RegisterDownscaledTexture(hash, sourcePath, destination); + await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false); return; } @@ -196,6 +212,7 @@ public sealed class TextureDownscaleService if (performanceConfig.EnableNonIndexTextureMipTrim && await TryDropTopMipAsync(hash, sourcePath, destination, targetMaxDimension, onlyDownscaleUncompressed, headerInfo).ConfigureAwait(false)) { + await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false); return; } @@ -206,6 +223,7 @@ public sealed class TextureDownscaleService _downscaledPaths[hash] = sourcePath; _logger.LogTrace("Skipping downscale for non-index texture {Hash}; no mip reduction required.", hash); + await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false); return; } @@ -213,6 +231,7 @@ public sealed class TextureDownscaleService { _downscaledPaths[hash] = sourcePath; _logger.LogTrace("Skipping downscale for index texture {Hash}; feature disabled.", hash); + await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false); return; } @@ -222,6 +241,7 @@ public sealed class TextureDownscaleService { _downscaledPaths[hash] = sourcePath; _logger.LogTrace("Skipping downscale for index texture {Hash}; header dimensions {Width}x{Height} within target.", hash, headerValue.Width, headerValue.Height); + await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false); return; } @@ -229,10 +249,12 @@ public sealed class TextureDownscaleService { _downscaledPaths[hash] = sourcePath; _logger.LogTrace("Skipping downscale for index texture {Hash}; block compressed format {Format}.", hash, headerInfo.Value.Format); + await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false); return; } using var sourceScratch = TexFileHelper.Load(sourcePath); + var sourceFormat = sourceScratch.Meta.Format; using var rgbaScratch = sourceScratch.GetRGBA(out var rgbaInfo).ThrowIfError(rgbaInfo); var bytesPerPixel = rgbaInfo.Meta.Format.BitsPerPixel() / 8; @@ -248,16 +270,39 @@ public sealed class TextureDownscaleService { _downscaledPaths[hash] = sourcePath; _logger.LogTrace("Skipping downscale for index texture {Hash}; already within bounds.", hash); + await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false); return; } using var resized = IndexDownscaler.Downscale(originalImage, targetSize.width, targetSize.height, BlockMultiple); + var canReencodeWithPenumbra = TryResolveCompressionTarget(headerInfo, sourceFormat, out var compressionTarget); using var resizedScratch = CreateScratchImage(resized, targetSize.width, targetSize.height); - using var finalScratch = resizedScratch.Convert(DXGIFormat.B8G8R8A8UNorm); + if (!TryConvertForSave(resizedScratch, sourceFormat, out var finalScratch, canReencodeWithPenumbra)) + { + if (canReencodeWithPenumbra + && await TryReencodeWithPenumbraAsync(hash, sourcePath, destination, resizedScratch, compressionTarget).ConfigureAwait(false)) + { + await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false); + return; + } - TexFileHelper.Save(destination, finalScratch); - RegisterDownscaledTexture(hash, sourcePath, destination); + _downscaledPaths[hash] = sourcePath; + _logger.LogTrace( + "Skipping downscale for index texture {Hash}; failed to re-encode to {Format}.", + hash, + sourceFormat); + await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false); + return; + } + + using (finalScratch) + { + TexFileHelper.Save(destination, finalScratch); + RegisterDownscaledTexture(hash, sourcePath, destination); + } + + await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false); } catch (Exception ex) { @@ -277,7 +322,6 @@ public sealed class TextureDownscaleService finally { _downscaleSemaphore.Release(); - _activeJobs.TryRemove(hash, out _); } } @@ -330,6 +374,157 @@ public sealed class TextureDownscaleService } } + private bool TryConvertForSave( + ScratchImage source, + DXGIFormat sourceFormat, + out ScratchImage result, + bool attemptPenumbraFallback) + { + var isCompressed = sourceFormat.IsCompressed(); + var targetFormat = isCompressed ? sourceFormat : DXGIFormat.B8G8R8A8UNorm; + try + { + result = source.Convert(targetFormat); + return true; + } + catch (Exception ex) + { + var compressedFallback = attemptPenumbraFallback + ? " Attempting Penumbra re-encode." + : " Skipping downscale."; + _logger.LogWarning( + ex, + "Failed to convert downscaled texture to {Format}.{Fallback}", + targetFormat, + isCompressed ? compressedFallback : " Falling back to B8G8R8A8."); + if (isCompressed) + { + result = default!; + return false; + } + + result = source.Convert(DXGIFormat.B8G8R8A8UNorm); + return true; + } + } + + private bool TryResolveCompressionTarget(TexHeaderInfo? headerInfo, DXGIFormat sourceFormat, out TextureCompressionTarget target) + { + if (headerInfo is { } info && TryGetCompressionTarget(info.Format, out target)) + { + return _textureCompressionService.IsTargetSelectable(target); + } + + if (sourceFormat.IsCompressed() && BlockCompressedFormatMap.TryGetValue((int)sourceFormat, out target)) + { + return _textureCompressionService.IsTargetSelectable(target); + } + + target = default; + return false; + } + + private async Task TryReencodeWithPenumbraAsync( + string hash, + string sourcePath, + string destination, + ScratchImage resizedScratch, + TextureCompressionTarget target) + { + try + { + using var uncompressed = resizedScratch.Convert(DXGIFormat.B8G8R8A8UNorm); + TexFileHelper.Save(destination, uncompressed); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to save uncompressed downscaled texture for {Hash}. Skipping downscale.", hash); + TryDelete(destination); + return false; + } + + await _compressionSemaphore.WaitAsync().ConfigureAwait(false); + try + { + var request = new TextureCompressionRequest(destination, Array.Empty(), target); + await _textureCompressionService + .ConvertTexturesAsync(new[] { request }, null, CancellationToken.None, requestRedraw: false) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to re-encode downscaled texture {Hash} to {Target}. Skipping downscale.", hash, target); + TryDelete(destination); + return false; + } + finally + { + _compressionSemaphore.Release(); + } + + RegisterDownscaledTexture(hash, sourcePath, destination); + _logger.LogDebug("Downscaled texture {Hash} -> {Path} (re-encoded via Penumbra).", hash, destination); + return true; + } + + private async Task TryAutoCompressAsync(string hash, string texturePath, TextureMapKind mapKind, TexHeaderInfo? headerInfo) + { + var performanceConfig = _playerPerformanceConfigService.Current; + if (!performanceConfig.EnableUncompressedTextureCompression) + { + return; + } + + if (string.IsNullOrEmpty(texturePath) || !File.Exists(texturePath)) + { + return; + } + + var info = headerInfo ?? (TryReadTexHeader(texturePath, out var header) ? header : (TexHeaderInfo?)null); + if (!info.HasValue) + { + _logger.LogTrace("Skipping auto-compress for texture {Hash}; unable to read header.", hash); + return; + } + + if (IsBlockCompressedFormat(info.Value.Format)) + { + _logger.LogTrace("Skipping auto-compress for texture {Hash}; already block-compressed.", hash); + return; + } + + var suggestion = TextureMetadataHelper.GetSuggestedTarget(info.Value.Format.ToString(), mapKind, texturePath); + if (suggestion is null) + { + return; + } + + var target = _textureCompressionService.NormalizeTarget(suggestion.Value.Target); + if (!_textureCompressionService.IsTargetSelectable(target)) + { + _logger.LogTrace("Skipping auto-compress for texture {Hash}; target {Target} not supported.", hash, target); + return; + } + + await _compressionSemaphore.WaitAsync().ConfigureAwait(false); + try + { + var includeMipMaps = !performanceConfig.SkipUncompressedTextureCompressionMipMaps; + var request = new TextureCompressionRequest(texturePath, Array.Empty(), target); + await _textureCompressionService + .ConvertTexturesAsync(new[] { request }, null, CancellationToken.None, requestRedraw: false, includeMipMaps: includeMipMaps) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Auto-compress failed for texture {Hash} ({Path})", hash, texturePath); + } + finally + { + _compressionSemaphore.Release(); + } + } + private static bool IsIndexMap(TextureMapKind kind) => kind is TextureMapKind.Mask or TextureMapKind.Index; diff --git a/LightlessSync/Services/UiService.cs b/LightlessSync/Services/UiService.cs index 16f0f4f..7cc9f9b 100644 --- a/LightlessSync/Services/UiService.cs +++ b/LightlessSync/Services/UiService.cs @@ -13,16 +13,20 @@ namespace LightlessSync.Services; public sealed class UiService : DisposableMediatorSubscriberBase { private readonly List _createdWindows = []; + private readonly List _registeredWindows = []; + private readonly HashSet _uiHiddenWindows = []; private readonly IUiBuilder _uiBuilder; private readonly FileDialogManager _fileDialogManager; private readonly ILogger _logger; private readonly LightlessConfigService _lightlessConfigService; + private readonly DalamudUtilService _dalamudUtilService; private readonly WindowSystem _windowSystem; private readonly UiFactory _uiFactory; private readonly PairFactory _pairFactory; + private bool _uiHideActive; public UiService(ILogger logger, IUiBuilder uiBuilder, - LightlessConfigService lightlessConfigService, WindowSystem windowSystem, + LightlessConfigService lightlessConfigService, DalamudUtilService dalamudUtilService, WindowSystem windowSystem, IEnumerable windows, UiFactory uiFactory, FileDialogManager fileDialogManager, LightlessMediator lightlessMediator, PairFactory pairFactory) : base(logger, lightlessMediator) @@ -31,6 +35,7 @@ public sealed class UiService : DisposableMediatorSubscriberBase _logger.LogTrace("Creating {type}", GetType().Name); _uiBuilder = uiBuilder; _lightlessConfigService = lightlessConfigService; + _dalamudUtilService = dalamudUtilService; _windowSystem = windowSystem; _uiFactory = uiFactory; _pairFactory = pairFactory; @@ -43,6 +48,7 @@ public sealed class UiService : DisposableMediatorSubscriberBase foreach (var window in windows) { + _registeredWindows.Add(window); _windowSystem.AddWindow(window); } @@ -176,6 +182,8 @@ public sealed class UiService : DisposableMediatorSubscriberBase { _windowSystem.RemoveWindow(msg.Window); _createdWindows.Remove(msg.Window); + _registeredWindows.Remove(msg.Window); + _uiHiddenWindows.Remove(msg.Window); msg.Window.Dispose(); }); } @@ -219,12 +227,72 @@ public sealed class UiService : DisposableMediatorSubscriberBase MainStyle.PushStyle(); try { + var hideOtherUi = ShouldHideOtherUi(); + UpdateUiHideState(hideOtherUi); _windowSystem.Draw(); - _fileDialogManager.Draw(); + if (!hideOtherUi) + _fileDialogManager.Draw(); } finally { MainStyle.PopStyle(); } } -} \ No newline at end of file + + private bool ShouldHideOtherUi() + { + var config = _lightlessConfigService.Current; + if (!config.ShowUiWhenUiHidden && _dalamudUtilService.IsGameUiHidden) + return true; + + if (!config.ShowUiInGpose && _dalamudUtilService.IsInGpose) + return true; + + return false; + } + + private void UpdateUiHideState(bool hideOtherUi) + { + if (!hideOtherUi) + { + if (_uiHideActive) + { + foreach (var window in _uiHiddenWindows) + { + window.IsOpen = true; + } + + _uiHiddenWindows.Clear(); + _uiHideActive = false; + } + + return; + } + + _uiHideActive = true; + foreach (var window in EnumerateManagedWindows()) + { + if (window is ZoneChatUi) + continue; + + if (!window.IsOpen) + continue; + + _uiHiddenWindows.Add(window); + window.IsOpen = false; + } + } + + private IEnumerable EnumerateManagedWindows() + { + foreach (var window in _registeredWindows) + { + yield return window; + } + + foreach (var window in _createdWindows) + { + yield return window; + } + } +} diff --git a/LightlessSync/ThirdParty/MeshDecimator/Algorithms/FastQuadricMeshSimplification.cs b/LightlessSync/ThirdParty/MeshDecimator/Algorithms/FastQuadricMeshSimplification.cs index fe22c85..31c001d 100644 --- a/LightlessSync/ThirdParty/MeshDecimator/Algorithms/FastQuadricMeshSimplification.cs +++ b/LightlessSync/ThirdParty/MeshDecimator/Algorithms/FastQuadricMeshSimplification.cs @@ -239,11 +239,14 @@ namespace MeshDecimator.Algorithms private ResizableArray vertNormals = null; private ResizableArray vertTangents = null; + private ResizableArray vertTangents2 = null; private UVChannels vertUV2D = null; private UVChannels vertUV3D = null; private UVChannels vertUV4D = null; private ResizableArray vertColors = null; private ResizableArray vertBoneWeights = null; + private ResizableArray vertPositionWs = null; + private ResizableArray vertNormalWs = null; private int remainingVertices = 0; @@ -508,10 +511,22 @@ namespace MeshDecimator.Algorithms { vertNormals[i0] = vertNormals[i1]; } + if (vertPositionWs != null) + { + vertPositionWs[i0] = vertPositionWs[i1]; + } + if (vertNormalWs != null) + { + vertNormalWs[i0] = vertNormalWs[i1]; + } if (vertTangents != null) { vertTangents[i0] = vertTangents[i1]; } + if (vertTangents2 != null) + { + vertTangents2[i0] = vertTangents2[i1]; + } if (vertUV2D != null) { for (int i = 0; i < Mesh.UVChannelCount; i++) @@ -561,10 +576,22 @@ namespace MeshDecimator.Algorithms { vertNormals[i0] = (vertNormals[i0] + vertNormals[i1]) * 0.5f; } + if (vertPositionWs != null) + { + vertPositionWs[i0] = (vertPositionWs[i0] + vertPositionWs[i1]) * 0.5f; + } + if (vertNormalWs != null) + { + vertNormalWs[i0] = (vertNormalWs[i0] + vertNormalWs[i1]) * 0.5f; + } if (vertTangents != null) { vertTangents[i0] = (vertTangents[i0] + vertTangents[i1]) * 0.5f; } + if (vertTangents2 != null) + { + vertTangents2[i0] = (vertTangents2[i0] + vertTangents2[i1]) * 0.5f; + } if (vertUV2D != null) { for (int i = 0; i < Mesh.UVChannelCount; i++) @@ -1080,11 +1107,14 @@ namespace MeshDecimator.Algorithms var vertNormals = (this.vertNormals != null ? this.vertNormals.Data : null); var vertTangents = (this.vertTangents != null ? this.vertTangents.Data : null); + var vertTangents2 = (this.vertTangents2 != null ? this.vertTangents2.Data : null); var vertUV2D = (this.vertUV2D != null ? this.vertUV2D.Data : null); var vertUV3D = (this.vertUV3D != null ? this.vertUV3D.Data : null); var vertUV4D = (this.vertUV4D != null ? this.vertUV4D.Data : null); var vertColors = (this.vertColors != null ? this.vertColors.Data : null); var vertBoneWeights = (this.vertBoneWeights != null ? this.vertBoneWeights.Data : null); + var vertPositionWs = (this.vertPositionWs != null ? this.vertPositionWs.Data : null); + var vertNormalWs = (this.vertNormalWs != null ? this.vertNormalWs.Data : null); var triangles = this.triangles.Data; int triangleCount = this.triangles.Length; @@ -1102,6 +1132,14 @@ namespace MeshDecimator.Algorithms { vertBoneWeights[iDest] = vertBoneWeights[iSrc]; } + if (vertPositionWs != null) + { + vertPositionWs[iDest] = vertPositionWs[iSrc]; + } + if (vertNormalWs != null) + { + vertNormalWs[iDest] = vertNormalWs[iSrc]; + } triangle.v0 = triangle.va0; } if (triangle.va1 != triangle.v1) @@ -1113,6 +1151,14 @@ namespace MeshDecimator.Algorithms { vertBoneWeights[iDest] = vertBoneWeights[iSrc]; } + if (vertPositionWs != null) + { + vertPositionWs[iDest] = vertPositionWs[iSrc]; + } + if (vertNormalWs != null) + { + vertNormalWs[iDest] = vertNormalWs[iSrc]; + } triangle.v1 = triangle.va1; } if (triangle.va2 != triangle.v2) @@ -1124,6 +1170,14 @@ namespace MeshDecimator.Algorithms { vertBoneWeights[iDest] = vertBoneWeights[iSrc]; } + if (vertPositionWs != null) + { + vertPositionWs[iDest] = vertPositionWs[iSrc]; + } + if (vertNormalWs != null) + { + vertNormalWs[iDest] = vertNormalWs[iSrc]; + } triangle.v2 = triangle.va2; } @@ -1153,6 +1207,7 @@ namespace MeshDecimator.Algorithms vertices[dst].p = vert.p; if (vertNormals != null) vertNormals[dst] = vertNormals[i]; if (vertTangents != null) vertTangents[dst] = vertTangents[i]; + if (vertTangents2 != null) vertTangents2[dst] = vertTangents2[i]; if (vertUV2D != null) { for (int j = 0; j < Mesh.UVChannelCount; j++) @@ -1188,6 +1243,8 @@ namespace MeshDecimator.Algorithms } if (vertColors != null) vertColors[dst] = vertColors[i]; if (vertBoneWeights != null) vertBoneWeights[dst] = vertBoneWeights[i]; + if (vertPositionWs != null) vertPositionWs[dst] = vertPositionWs[i]; + if (vertNormalWs != null) vertNormalWs[dst] = vertNormalWs[i]; } ++dst; } @@ -1206,11 +1263,14 @@ namespace MeshDecimator.Algorithms this.vertices.Resize(vertexCount); if (vertNormals != null) this.vertNormals.Resize(vertexCount, true); if (vertTangents != null) this.vertTangents.Resize(vertexCount, true); + if (vertTangents2 != null) this.vertTangents2.Resize(vertexCount, true); if (vertUV2D != null) this.vertUV2D.Resize(vertexCount, true); if (vertUV3D != null) this.vertUV3D.Resize(vertexCount, true); if (vertUV4D != null) this.vertUV4D.Resize(vertexCount, true); if (vertColors != null) this.vertColors.Resize(vertexCount, true); if (vertBoneWeights != null) this.vertBoneWeights.Resize(vertexCount, true); + if (vertPositionWs != null) this.vertPositionWs.Resize(vertexCount, true); + if (vertNormalWs != null) this.vertNormalWs.Resize(vertexCount, true); } #endregion #endregion @@ -1230,7 +1290,10 @@ namespace MeshDecimator.Algorithms int meshTriangleCount = mesh.TriangleCount; var meshVertices = mesh.Vertices; var meshNormals = mesh.Normals; + var meshPositionWs = mesh.PositionWs; + var meshNormalWs = mesh.NormalWs; var meshTangents = mesh.Tangents; + var meshTangents2 = mesh.Tangents2; var meshColors = mesh.Colors; var meshBoneWeights = mesh.BoneWeights; subMeshCount = meshSubMeshCount; @@ -1260,7 +1323,10 @@ namespace MeshDecimator.Algorithms } vertNormals = InitializeVertexAttribute(meshNormals, "normals"); + vertPositionWs = InitializeVertexAttribute(meshPositionWs, "positionWs"); + vertNormalWs = InitializeVertexAttribute(meshNormalWs, "normalWs"); vertTangents = InitializeVertexAttribute(meshTangents, "tangents"); + vertTangents2 = InitializeVertexAttribute(meshTangents2, "tangents2"); vertColors = InitializeVertexAttribute(meshColors, "colors"); vertBoneWeights = InitializeVertexAttribute(meshBoneWeights, "boneWeights"); @@ -1492,10 +1558,22 @@ namespace MeshDecimator.Algorithms { newMesh.Normals = vertNormals.Data; } + if (vertPositionWs != null) + { + newMesh.PositionWs = vertPositionWs.Data; + } + if (vertNormalWs != null) + { + newMesh.NormalWs = vertNormalWs.Data; + } if (vertTangents != null) { newMesh.Tangents = vertTangents.Data; } + if (vertTangents2 != null) + { + newMesh.Tangents2 = vertTangents2.Data; + } if (vertColors != null) { newMesh.Colors = vertColors.Data; diff --git a/LightlessSync/ThirdParty/MeshDecimator/Mesh.cs b/LightlessSync/ThirdParty/MeshDecimator/Mesh.cs index 2e38821..416ad4e 100644 --- a/LightlessSync/ThirdParty/MeshDecimator/Mesh.cs +++ b/LightlessSync/ThirdParty/MeshDecimator/Mesh.cs @@ -47,11 +47,14 @@ namespace MeshDecimator private int[][] indices = null; private Vector3[] normals = null; private Vector4[] tangents = null; + private Vector4[] tangents2 = null; private Vector2[][] uvs2D = null; private Vector3[][] uvs3D = null; private Vector4[][] uvs4D = null; private Vector4[] colors = null; private BoneWeight[] boneWeights = null; + private float[] positionWs = null; + private float[] normalWs = null; private static readonly int[] emptyIndices = new int[0]; #endregion @@ -168,6 +171,36 @@ namespace MeshDecimator } } + /// + /// Gets or sets the position W components for this mesh. + /// + public float[] PositionWs + { + get { return positionWs; } + set + { + if (value != null && value.Length != vertices.Length) + throw new ArgumentException(string.Format("The position Ws must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length)); + + positionWs = value; + } + } + + /// + /// Gets or sets the normal W components for this mesh. + /// + public float[] NormalWs + { + get { return normalWs; } + set + { + if (value != null && value.Length != vertices.Length) + throw new ArgumentException(string.Format("The normal Ws must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length)); + + normalWs = value; + } + } + /// /// Gets or sets the tangents for this mesh. /// @@ -183,6 +216,21 @@ namespace MeshDecimator } } + /// + /// Gets or sets the second tangent set for this mesh. + /// + public Vector4[] Tangents2 + { + get { return tangents2; } + set + { + if (value != null && value.Length != vertices.Length) + throw new ArgumentException(string.Format("The second vertex tangents must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length)); + + tangents2 = value; + } + } + /// /// Gets or sets the first UV set for this mesh. /// @@ -298,11 +346,14 @@ namespace MeshDecimator { normals = null; tangents = null; + tangents2 = null; uvs2D = null; uvs3D = null; uvs4D = null; colors = null; boneWeights = null; + positionWs = null; + normalWs = null; } #endregion diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index a43f228..97763a1 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -28,7 +28,6 @@ using System.Collections.Immutable; using System.Globalization; using System.Numerics; using System.Reflection; -using System.Runtime.InteropServices; namespace LightlessSync.UI; @@ -71,6 +70,7 @@ public class CompactUi : WindowMediatorSubscriberBase private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi; private readonly SeluneBrush _seluneBrush = new(); private readonly TopTabMenu _tabMenu; + private readonly OptimizationSummaryCard _optimizationSummaryCard; #endregion @@ -86,7 +86,8 @@ public class CompactUi : WindowMediatorSubscriberBase private int _pendingFocusFrame = -1; private Pair? _pendingFocusPair; private bool _showModalForUserAddition; - private float _transferPartHeight; + private float _footerPartHeight; + private bool _hasFooterPartHeight; private bool _wasOpen; private float _windowContentWidth; @@ -177,6 +178,7 @@ public class CompactUi : WindowMediatorSubscriberBase _characterAnalyzer = characterAnalyzer; _playerPerformanceConfig = playerPerformanceConfig; _lightlessMediator = mediator; + _optimizationSummaryCard = new OptimizationSummaryCard(_uiSharedService, _pairUiService, _playerPerformanceConfig, _fileTransferManager, _lightlessMediator); } #endregion @@ -262,12 +264,17 @@ public class CompactUi : WindowMediatorSubscriberBase using (ImRaii.PushId("global-topmenu")) _tabMenu.Draw(pairSnapshot); using (ImRaii.PushId("pairlist")) DrawPairs(); - var transfersTop = ImGui.GetCursorScreenPos().Y; - var gradientBottom = MathF.Max(gradientTop, transfersTop - style.ItemSpacing.Y - gradientInset); + var footerTop = ImGui.GetCursorScreenPos().Y; + var gradientBottom = MathF.Max(gradientTop, footerTop - style.ItemSpacing.Y - gradientInset); selune.DrawGradient(gradientTop, gradientBottom, ImGui.GetIO().DeltaTime); float pairlistEnd = ImGui.GetCursorPosY(); - using (ImRaii.PushId("transfers")) DrawTransfers(); - _transferPartHeight = ImGui.GetCursorPosY() - pairlistEnd - ImGui.GetTextLineHeight(); + bool drewFooter; + using (ImRaii.PushId("optimization-summary")) + { + drewFooter = _optimizationSummaryCard.Draw(_currentDownloads.Count); + } + _footerPartHeight = drewFooter ? ImGui.GetCursorPosY() - pairlistEnd : 0f; + _hasFooterPartHeight = true; using (ImRaii.PushId("group-pair-popup")) _selectPairsForGroupUi.Draw(pairSnapshot.DirectPairs); using (ImRaii.PushId("group-syncshell-popup")) _selectSyncshellForTagUi.Draw(pairSnapshot.Groups); using (ImRaii.PushId("group-pair-edit")) _renamePairTagUi.Draw(); @@ -330,10 +337,9 @@ public class CompactUi : WindowMediatorSubscriberBase private void DrawPairs() { - float ySize = Math.Abs(_transferPartHeight) < 0.0001f + float ySize = !_hasFooterPartHeight ? 1 - : (ImGui.GetWindowContentRegionMax().Y - ImGui.GetWindowContentRegionMin().Y - + ImGui.GetTextLineHeight() - ImGui.GetStyle().WindowPadding.Y - ImGui.GetStyle().WindowBorderSize) - _transferPartHeight - ImGui.GetCursorPosY(); + : MathF.Max(1f, ImGui.GetContentRegionAvail().Y - _footerPartHeight); if (ImGui.BeginChild("list", new Vector2(_windowContentWidth, ySize), border: false)) { @@ -346,101 +352,6 @@ public class CompactUi : WindowMediatorSubscriberBase ImGui.EndChild(); } - private void DrawTransfers() - { - var currentUploads = _fileTransferManager.GetCurrentUploadsSnapshot(); - ImGui.AlignTextToFramePadding(); - _uiSharedService.IconText(FontAwesomeIcon.Upload); - ImGui.SameLine(35 * ImGuiHelpers.GlobalScale); - - if (currentUploads.Count > 0) - { - int totalUploads = currentUploads.Count; - int doneUploads = 0; - long totalUploaded = 0; - long totalToUpload = 0; - - foreach (var upload in currentUploads) - { - if (upload.IsTransferred) - { - doneUploads++; - } - - totalUploaded += upload.Transferred; - totalToUpload += upload.Total; - } - - int activeUploads = totalUploads - doneUploads; - var uploadSlotLimit = Math.Clamp(_configService.Current.ParallelUploads, 1, 8); - - ImGui.TextUnformatted($"{doneUploads}/{totalUploads} (slots {activeUploads}/{uploadSlotLimit})"); - var uploadText = $"({UiSharedService.ByteToString(totalUploaded)}/{UiSharedService.ByteToString(totalToUpload)})"; - var textSize = ImGui.CalcTextSize(uploadText); - ImGui.SameLine(_windowContentWidth - textSize.X); - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted(uploadText); - } - else - { - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted("No uploads in progress"); - } - - var downloadSummary = GetDownloadSummary(); - ImGui.AlignTextToFramePadding(); - _uiSharedService.IconText(FontAwesomeIcon.Download); - ImGui.SameLine(35 * ImGuiHelpers.GlobalScale); - - if (downloadSummary.HasDownloads) - { - var totalDownloads = downloadSummary.TotalFiles; - var doneDownloads = downloadSummary.TransferredFiles; - var totalDownloaded = downloadSummary.TransferredBytes; - var totalToDownload = downloadSummary.TotalBytes; - - ImGui.TextUnformatted($"{doneDownloads}/{totalDownloads}"); - var downloadText = - $"({UiSharedService.ByteToString(totalDownloaded)}/{UiSharedService.ByteToString(totalToDownload)})"; - var textSize = ImGui.CalcTextSize(downloadText); - ImGui.SameLine(_windowContentWidth - textSize.X); - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted(downloadText); - } - else - { - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted("No downloads in progress"); - } - } - - - private DownloadSummary GetDownloadSummary() - { - long totalBytes = 0; - long transferredBytes = 0; - int totalFiles = 0; - int transferredFiles = 0; - - foreach (var kvp in _currentDownloads.ToArray()) - { - if (kvp.Value is not { Count: > 0 } statuses) - { - continue; - } - - foreach (var status in statuses.Values) - { - totalBytes += status.TotalBytes; - transferredBytes += status.TransferredBytes; - totalFiles += status.TotalFiles; - transferredFiles += status.TransferredFiles; - } - } - - return new DownloadSummary(totalFiles, transferredFiles, transferredBytes, totalBytes); - } - #endregion #region Header Drawing @@ -1147,13 +1058,4 @@ public class CompactUi : WindowMediatorSubscriberBase #endregion - #region Helper Types - - [StructLayout(LayoutKind.Auto)] - private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes) - { - public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0; - } - - #endregion } diff --git a/LightlessSync/UI/Components/DrawFolderBase.cs b/LightlessSync/UI/Components/DrawFolderBase.cs index 0532da9..39a1b44 100644 --- a/LightlessSync/UI/Components/DrawFolderBase.cs +++ b/LightlessSync/UI/Components/DrawFolderBase.cs @@ -39,7 +39,8 @@ public abstract class DrawFolderBase : IDrawFolder public void Draw() { - if (!RenderIfEmpty && !DrawPairs.Any()) return; + var drawPairCount = DrawPairs.Count; + if (!RenderIfEmpty && drawPairCount == 0) return; _suppressNextRowToggle = false; @@ -111,9 +112,9 @@ public abstract class DrawFolderBase : IDrawFolder if (_tagHandler.IsTagOpen(_id)) { using var indent = ImRaii.PushIndent(_uiSharedService.GetIconSize(FontAwesomeIcon.EllipsisV).X + ImGui.GetStyle().ItemSpacing.X, false); - if (DrawPairs.Any()) + if (drawPairCount > 0) { - using var clipper = ImUtf8.ListClipper(DrawPairs.Count, ImGui.GetFrameHeightWithSpacing()); + using var clipper = ImUtf8.ListClipper(drawPairCount, ImGui.GetFrameHeightWithSpacing()); while (clipper.Step()) { for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) diff --git a/LightlessSync/UI/Components/DrawGroupedGroupFolder.cs b/LightlessSync/UI/Components/DrawGroupedGroupFolder.cs index 72063f2..e13106d 100644 --- a/LightlessSync/UI/Components/DrawGroupedGroupFolder.cs +++ b/LightlessSync/UI/Components/DrawGroupedGroupFolder.cs @@ -22,13 +22,16 @@ public class DrawGroupedGroupFolder : IDrawFolder private readonly ApiController _apiController; private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi; private readonly RenameSyncshellTagUi _renameSyncshellTagUi; + private readonly HashSet _onlinePairBuffer = new(StringComparer.Ordinal); + private IImmutableList? _drawPairsCache; + private int? _totalPairsCache; private bool _wasHovered = false; private float _menuWidth; private bool _rowClickArmed; - public IImmutableList DrawPairs => _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).ToImmutableList(); - public int OnlinePairs => _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).Where(g => g.Pair.IsOnline).DistinctBy(g => g.Pair.UserData.UID).Count(); - public int TotalPairs => _groups.Sum(g => g.GroupDrawFolder.TotalPairs); + public IImmutableList DrawPairs => _drawPairsCache ??= _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).ToImmutableList(); + public int OnlinePairs => CountOnlinePairs(DrawPairs); + public int TotalPairs => _totalPairsCache ??= _groups.Sum(g => g.GroupDrawFolder.TotalPairs); public DrawGroupedGroupFolder(IEnumerable groups, TagHandler tagHandler, ApiController apiController, UiSharedService uiSharedService, SelectSyncshellForTagUi selectSyncshellForTagUi, RenameSyncshellTagUi renameSyncshellTagUi, string tag) { @@ -50,6 +53,10 @@ public class DrawGroupedGroupFolder : IDrawFolder } using var id = ImRaii.PushId(_id); + var drawPairs = DrawPairs; + var onlinePairs = CountOnlinePairs(drawPairs); + var totalPairs = TotalPairs; + var hasPairs = drawPairs.Count > 0; var color = ImRaii.PushColor(ImGuiCol.ChildBg, ImGui.GetColorU32(ImGuiCol.FrameBgHovered), _wasHovered); var allowRowClick = string.IsNullOrEmpty(_tag); var suppressRowToggle = false; @@ -85,10 +92,10 @@ public class DrawGroupedGroupFolder : IDrawFolder { ImGui.SameLine(); ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted("[" + OnlinePairs.ToString() + "]"); + ImGui.TextUnformatted("[" + onlinePairs.ToString() + "]"); } - UiSharedService.AttachToolTip(OnlinePairs + " online in all of your joined syncshells" + Environment.NewLine + - TotalPairs + " pairs combined in all of your joined syncshells"); + UiSharedService.AttachToolTip(onlinePairs + " online in all of your joined syncshells" + Environment.NewLine + + totalPairs + " pairs combined in all of your joined syncshells"); ImGui.SameLine(); ImGui.AlignTextToFramePadding(); if (_tag != "") @@ -96,7 +103,7 @@ public class DrawGroupedGroupFolder : IDrawFolder ImGui.TextUnformatted(_tag); ImGui.SameLine(); - DrawPauseButton(); + DrawPauseButton(hasPairs); ImGui.SameLine(); DrawMenu(ref suppressRowToggle); } else @@ -104,7 +111,7 @@ public class DrawGroupedGroupFolder : IDrawFolder ImGui.TextUnformatted("All Syncshells"); ImGui.SameLine(); - DrawPauseButton(); + DrawPauseButton(hasPairs); } } color.Dispose(); @@ -151,9 +158,9 @@ public class DrawGroupedGroupFolder : IDrawFolder } } - protected void DrawPauseButton() + protected void DrawPauseButton(bool hasPairs) { - if (DrawPairs.Count > 0) + if (hasPairs) { var isPaused = _groups.Select(g => g.GroupFullInfo).All(g => g.GroupUserPermissions.IsPaused()); FontAwesomeIcon pauseIcon = isPaused ? FontAwesomeIcon.Play : FontAwesomeIcon.Pause; @@ -179,6 +186,27 @@ public class DrawGroupedGroupFolder : IDrawFolder } } + private int CountOnlinePairs(IImmutableList drawPairs) + { + if (drawPairs.Count == 0) + { + return 0; + } + + _onlinePairBuffer.Clear(); + foreach (var pair in drawPairs) + { + if (!pair.Pair.IsOnline) + { + continue; + } + + _onlinePairBuffer.Add(pair.Pair.UserData.UID); + } + + return _onlinePairBuffer.Count; + } + protected void ChangePauseStateGroups() { foreach(var group in _groups) diff --git a/LightlessSync/UI/Components/DrawUserPair.cs b/LightlessSync/UI/Components/DrawUserPair.cs index 5524226..3ee10ad 100644 --- a/LightlessSync/UI/Components/DrawUserPair.cs +++ b/LightlessSync/UI/Components/DrawUserPair.cs @@ -340,7 +340,10 @@ public class DrawUserPair ? FontAwesomeIcon.User : FontAwesomeIcon.Users); } - UiSharedService.AttachToolTip(GetUserTooltip()); + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + { + UiSharedService.AttachToolTip(GetUserTooltip()); + } if (_performanceConfigService.Current.ShowPerformanceIndicator && !_performanceConfigService.Current.UIDsToIgnore @@ -354,22 +357,25 @@ public class DrawUserPair _uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow")); - string userWarningText = "WARNING: This user exceeds one or more of your defined thresholds:" + UiSharedService.TooltipSeparator; - bool shownVram = false; - if (_performanceConfigService.Current.VRAMSizeWarningThresholdMiB > 0 - && _performanceConfigService.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024 < _pair.LastAppliedApproximateVRAMBytes) + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) { - shownVram = true; - userWarningText += $"Approx. VRAM Usage: Used: {UiSharedService.ByteToString(_pair.LastAppliedApproximateVRAMBytes)}, Threshold: {_performanceConfigService.Current.VRAMSizeWarningThresholdMiB} MiB"; - } - if (_performanceConfigService.Current.TrisWarningThresholdThousands > 0 - && _performanceConfigService.Current.TrisWarningThresholdThousands * 1024 < _pair.LastAppliedDataTris) - { - if (shownVram) userWarningText += Environment.NewLine; - userWarningText += $"Approx. Triangle count: Used: {_pair.LastAppliedDataTris}, Threshold: {_performanceConfigService.Current.TrisWarningThresholdThousands * 1000}"; - } + string userWarningText = "WARNING: This user exceeds one or more of your defined thresholds:" + UiSharedService.TooltipSeparator; + bool shownVram = false; + if (_performanceConfigService.Current.VRAMSizeWarningThresholdMiB > 0 + && _performanceConfigService.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024 < _pair.LastAppliedApproximateVRAMBytes) + { + shownVram = true; + userWarningText += $"Approx. VRAM Usage: Used: {UiSharedService.ByteToString(_pair.LastAppliedApproximateVRAMBytes)}, Threshold: {_performanceConfigService.Current.VRAMSizeWarningThresholdMiB} MiB"; + } + if (_performanceConfigService.Current.TrisWarningThresholdThousands > 0 + && _performanceConfigService.Current.TrisWarningThresholdThousands * 1024 < _pair.LastAppliedDataTris) + { + if (shownVram) userWarningText += Environment.NewLine; + userWarningText += $"Approx. Triangle count: Used: {_pair.LastAppliedDataTris}, Threshold: {_performanceConfigService.Current.TrisWarningThresholdThousands * 1000}"; + } - UiSharedService.AttachToolTip(userWarningText); + UiSharedService.AttachToolTip(userWarningText); + } } ImGui.SameLine(); @@ -613,12 +619,15 @@ public class DrawUserPair perm.SetPaused(!perm.IsPaused()); _ = _apiController.UserSetPairPermissions(new(_pair.UserData, perm)); } - UiSharedService.AttachToolTip(!_pair.UserPair!.OwnPermissions.IsPaused() - ? ("Pause pairing with " + _pair.UserData.AliasOrUID - + (_pair.UserPair!.OwnPermissions.IsSticky() - ? string.Empty - : UiSharedService.TooltipSeparator + "Hold CTRL to enable preferred permissions while pausing." + Environment.NewLine + "This will leave this pair paused even if unpausing syncshells including this pair.")) - : "Resume pairing with " + _pair.UserData.AliasOrUID); + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + { + UiSharedService.AttachToolTip(!_pair.UserPair!.OwnPermissions.IsPaused() + ? ("Pause pairing with " + _pair.UserData.AliasOrUID + + (_pair.UserPair!.OwnPermissions.IsSticky() + ? string.Empty + : UiSharedService.TooltipSeparator + "Hold CTRL to enable preferred permissions while pausing." + Environment.NewLine + "This will leave this pair paused even if unpausing syncshells including this pair.")) + : "Resume pairing with " + _pair.UserData.AliasOrUID); + } if (_pair.IsPaired) { @@ -781,8 +790,11 @@ public class DrawUserPair currentRightSide -= (_uiSharedService.GetIconSize(FontAwesomeIcon.Running).X + (spacingX / 2f)); ImGui.SameLine(currentRightSide); _uiSharedService.IconText(FontAwesomeIcon.Running); - UiSharedService.AttachToolTip($"This user has shared {sharedData.Count} Character Data Sets with you." + UiSharedService.TooltipSeparator - + "Click to open the Character Data Hub and show the entries."); + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + { + UiSharedService.AttachToolTip($"This user has shared {sharedData.Count} Character Data Sets with you." + UiSharedService.TooltipSeparator + + "Click to open the Character Data Hub and show the entries."); + } if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) { _mediator.Publish(new OpenCharaDataHubWithFilterMessage(_pair.UserData)); diff --git a/LightlessSync/UI/Components/OptimizationSettingsPanel.cs b/LightlessSync/UI/Components/OptimizationSettingsPanel.cs new file mode 100644 index 0000000..a75df2d --- /dev/null +++ b/LightlessSync/UI/Components/OptimizationSettingsPanel.cs @@ -0,0 +1,930 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using LightlessSync.LightlessConfiguration; +using LightlessSync.LightlessConfiguration.Configurations; +using LightlessSync.PlayerData.Pairs; +using LightlessSync.UI.Services; +using LightlessSync.Utils; +using System.Numerics; + +namespace LightlessSync.UI.Components; + +public enum OptimizationPanelSection +{ + Texture, + Model, +} + +public sealed class OptimizationSettingsPanel +{ + private readonly UiSharedService _uiSharedService; + private readonly PlayerPerformanceConfigService _performanceConfigService; + private readonly PairUiService _pairUiService; + + private const ImGuiTableFlags SettingsTableFlags = ImGuiTableFlags.SizingStretchProp + | ImGuiTableFlags.NoBordersInBody + | ImGuiTableFlags.PadOuterX; + + public OptimizationSettingsPanel( + UiSharedService uiSharedService, + PlayerPerformanceConfigService performanceConfigService, + PairUiService pairUiService) + { + _uiSharedService = uiSharedService; + _performanceConfigService = performanceConfigService; + _pairUiService = pairUiService; + } + + public void DrawSettingsTrees( + string textureLabel, + Vector4 textureColor, + string modelLabel, + Vector4 modelColor, + Func beginTree) + { + if (beginTree(textureLabel, textureColor)) + { + DrawTextureSection(showTitle: false); + UiSharedService.ColoredSeparator(textureColor, 1.5f); + ImGui.TreePop(); + } + + ImGui.Separator(); + + if (beginTree(modelLabel, modelColor)) + { + DrawModelSection(showTitle: false); + UiSharedService.ColoredSeparator(modelColor, 1.5f); + ImGui.TreePop(); + } + } + + public void DrawPopup(OptimizationPanelSection section) + { + switch (section) + { + case OptimizationPanelSection.Texture: + DrawTextureSection(showTitle: false); + break; + case OptimizationPanelSection.Model: + DrawModelSection(showTitle: false); + break; + } + } + + private void DrawTextureSection(bool showTitle) + { + var scale = ImGuiHelpers.GlobalScale; + DrawSectionIntro( + FontAwesomeIcon.Images, + UIColors.Get("LightlessYellow"), + "Texture Optimization", + "Reduce texture memory by trimming mip levels and downscaling oversized textures.", + showTitle); + + DrawCallout("texture-opt-warning", UIColors.Get("DimRed"), () => + { + _uiSharedService.MediumText("Warning", UIColors.Get("DimRed")); + _uiSharedService.DrawNoteLine("! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("Texture compression and downscaling is potentially a "), + new SeStringUtils.RichTextEntry("destructive", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry(" process and may cause broken or incorrect character appearances.")); + + _uiSharedService.DrawNoteLine("! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("This feature is encouraged to help "), + new SeStringUtils.RichTextEntry("lower-end systems with limited VRAM", UIColors.Get("LightlessYellow"), true), + new SeStringUtils.RichTextEntry(" and for use in "), + new SeStringUtils.RichTextEntry("performance-critical scenarios", UIColors.Get("LightlessYellow"), true), + new SeStringUtils.RichTextEntry(".")); + + _uiSharedService.DrawNoteLine("! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("Runtime downscaling "), + new SeStringUtils.RichTextEntry("MAY", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry(" cause higher load on the system when processing downloads.")); + + _uiSharedService.DrawNoteLine("!!! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("When enabled, we cannot provide support for appearance issues caused by this setting!", UIColors.Get("DimRed"), true)); + }); + + ImGui.Dummy(new Vector2(0f, 2f * scale)); + DrawGroupHeader("Core Controls", UIColors.Get("LightlessYellow")); + + var textureConfig = _performanceConfigService.Current; + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + using (var table = ImRaii.Table("texture-opt-core", 3, SettingsTableFlags)) + { + if (table) + { + ImGui.TableSetupColumn("Label", ImGuiTableColumnFlags.WidthFixed, 220f * scale); + ImGui.TableSetupColumn("Control", ImGuiTableColumnFlags.WidthFixed, 180f * scale); + ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + + DrawControlRow("Trim mip levels", () => + { + var trimNonIndex = textureConfig.EnableNonIndexTextureMipTrim; + var accent = UIColors.Get("LightlessYellow"); + if (DrawAccentCheckbox("##texture-trim-mips", ref trimNonIndex, accent)) + { + textureConfig.EnableNonIndexTextureMipTrim = trimNonIndex; + _performanceConfigService.Save(); + } + }, "Removes high-resolution mip levels from oversized non-index textures.", UIColors.Get("LightlessYellow"), UIColors.Get("LightlessYellow")); + + DrawControlRow("Downscale index textures", () => + { + var downscaleIndex = textureConfig.EnableIndexTextureDownscale; + var accent = UIColors.Get("LightlessYellow"); + if (DrawAccentCheckbox("##texture-downscale-index", ref downscaleIndex, accent)) + { + textureConfig.EnableIndexTextureDownscale = downscaleIndex; + _performanceConfigService.Save(); + } + }, "Downscales oversized index textures to the configured dimension.", UIColors.Get("LightlessYellow"), UIColors.Get("LightlessYellow")); + + DrawControlRow("Max texture dimension", () => + { + var dimensionOptions = new[] { 512, 1024, 2048, 4096 }; + var optionLabels = dimensionOptions.Select(static value => value.ToString()).ToArray(); + var currentDimension = textureConfig.TextureDownscaleMaxDimension; + var selectedIndex = Array.IndexOf(dimensionOptions, currentDimension); + if (selectedIndex < 0) + { + selectedIndex = Array.IndexOf(dimensionOptions, 2048); + } + + ImGui.SetNextItemWidth(-1f); + if (ImGui.Combo("##texture-max-dimension", ref selectedIndex, optionLabels, optionLabels.Length)) + { + textureConfig.TextureDownscaleMaxDimension = dimensionOptions[selectedIndex]; + _performanceConfigService.Save(); + } + }, "Textures above this size are reduced to the limit. Default: 2048."); + } + } + + if (!textureConfig.EnableNonIndexTextureMipTrim + && !textureConfig.EnableIndexTextureDownscale + && !textureConfig.EnableUncompressedTextureCompression) + { + UiSharedService.ColorTextWrapped( + "Texture trimming, downscale, and compression are disabled. Lightless will keep original textures regardless of size.", + UIColors.Get("DimRed")); + } + + ImGui.Dummy(new Vector2(0f, 4f * scale)); + DrawGroupHeader("Behavior & Exceptions", UIColors.Get("LightlessYellow")); + + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + using (var table = ImRaii.Table("texture-opt-behavior", 3, SettingsTableFlags)) + { + if (table) + { + ImGui.TableSetupColumn("Label", ImGuiTableColumnFlags.WidthFixed, 220f * scale); + ImGui.TableSetupColumn("Control", ImGuiTableColumnFlags.WidthFixed, 180f * scale); + ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + + DrawControlRow("Only downscale uncompressed", () => + { + var onlyUncompressed = textureConfig.OnlyDownscaleUncompressedTextures; + if (ImGui.Checkbox("##texture-only-uncompressed", ref onlyUncompressed)) + { + textureConfig.OnlyDownscaleUncompressedTextures = onlyUncompressed; + _performanceConfigService.Save(); + } + }, "When disabled, block-compressed textures can be downscaled too."); + } + } + + ImGui.Dummy(new Vector2(0f, 2f * scale)); + DrawTextureCompressionCard(textureConfig); + + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + using (var table = ImRaii.Table("texture-opt-behavior-extra", 3, SettingsTableFlags)) + { + if (table) + { + ImGui.TableSetupColumn("Label", ImGuiTableColumnFlags.WidthFixed, 220f * scale); + ImGui.TableSetupColumn("Control", ImGuiTableColumnFlags.WidthFixed, 180f * scale); + ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + + DrawControlRow("Keep original texture files", () => + { + var keepOriginalTextures = textureConfig.KeepOriginalTextureFiles; + if (ImGui.Checkbox("##texture-keep-original", ref keepOriginalTextures)) + { + textureConfig.KeepOriginalTextureFiles = keepOriginalTextures; + _performanceConfigService.Save(); + } + }, "Keeps the original texture alongside the downscaled copy."); + + DrawControlRow("Skip preferred/direct pairs", () => + { + var skipPreferredDownscale = textureConfig.SkipTextureDownscaleForPreferredPairs; + if (ImGui.Checkbox("##texture-skip-preferred", ref skipPreferredDownscale)) + { + textureConfig.SkipTextureDownscaleForPreferredPairs = skipPreferredDownscale; + _performanceConfigService.Save(); + } + }, "Leaves textures untouched for preferred/direct pairs."); + } + } + + UiSharedService.ColorTextWrapped( + "Note: Disabling \"Keep original texture files\" prevents saved/effective VRAM usage information.", + UIColors.Get("LightlessYellow")); + + ImGui.Dummy(new Vector2(0f, 4f * scale)); + DrawSummaryPanel("Usage Summary", UIColors.Get("LightlessPurple"), DrawTextureDownscaleCounters); + } + + private void DrawModelSection(bool showTitle) + { + var scale = ImGuiHelpers.GlobalScale; + DrawSectionIntro( + FontAwesomeIcon.ProjectDiagram, + UIColors.Get("LightlessOrange"), + "Model Optimization", + "Reduce triangle counts by decimating models above a threshold.", + showTitle); + + DrawCallout("model-opt-warning", UIColors.Get("DimRed"), () => + { + _uiSharedService.MediumText("Warning", UIColors.Get("DimRed")); + _uiSharedService.DrawNoteLine("! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("Model decimation is a "), + new SeStringUtils.RichTextEntry("destructive", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry(" process and may cause broken or incorrect character appearances.")); + + _uiSharedService.DrawNoteLine("! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("This feature is encouraged to help "), + new SeStringUtils.RichTextEntry("lower-end systems with limited VRAM", UIColors.Get("LightlessYellow"), true), + new SeStringUtils.RichTextEntry(" and for use in "), + new SeStringUtils.RichTextEntry("performance-critical scenarios", UIColors.Get("LightlessYellow"), true), + new SeStringUtils.RichTextEntry(".")); + + _uiSharedService.DrawNoteLine("! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("Runtime decimation "), + new SeStringUtils.RichTextEntry("MAY", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry(" cause higher load on the system when processing downloads.")); + + _uiSharedService.DrawNoteLine("!!! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("When enabled, we cannot provide support for appearance issues caused by this setting!", UIColors.Get("DimRed"), true)); + }); + + ImGui.Dummy(new Vector2(0f, 2f * scale)); + DrawCallout("model-opt-behavior", UIColors.Get("LightlessGreen"), () => + { + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessGreen"), + new SeStringUtils.RichTextEntry("Meshes above the "), + new SeStringUtils.RichTextEntry("triangle threshold", UIColors.Get("LightlessGreen"), true), + new SeStringUtils.RichTextEntry(" will be decimated to the "), + new SeStringUtils.RichTextEntry("target ratio", UIColors.Get("LightlessGreen"), true), + new SeStringUtils.RichTextEntry(". This can reduce quality or alter intended structure.")); + }); + + DrawGroupHeader("Core Controls", UIColors.Get("LightlessOrange")); + var performanceConfig = _performanceConfigService.Current; + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + using (var table = ImRaii.Table("model-opt-core", 3, SettingsTableFlags)) + { + if (table) + { + ImGui.TableSetupColumn("Label", ImGuiTableColumnFlags.WidthFixed, 220f * scale); + ImGui.TableSetupColumn("Control", ImGuiTableColumnFlags.WidthFixed, 180f * scale); + ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + + DrawControlRow("Enable model decimation", () => + { + var enableDecimation = performanceConfig.EnableModelDecimation; + var accent = UIColors.Get("LightlessOrange"); + if (DrawAccentCheckbox("##enable-model-decimation", ref enableDecimation, accent)) + { + performanceConfig.EnableModelDecimation = enableDecimation; + _performanceConfigService.Save(); + } + }, "Generates a decimated copy of models after download.", UIColors.Get("LightlessOrange"), UIColors.Get("LightlessOrange")); + + DrawControlRow("Decimate above (triangles)", () => + { + var triangleThreshold = performanceConfig.ModelDecimationTriangleThreshold; + ImGui.SetNextItemWidth(-1f); + if (ImGui.SliderInt("##model-decimation-threshold", ref triangleThreshold, 1_000, 100_000)) + { + performanceConfig.ModelDecimationTriangleThreshold = Math.Clamp(triangleThreshold, 1_000, 100_000); + _performanceConfigService.Save(); + } + }, "Models below this triangle count are left untouched. Default: 15,000."); + + DrawControlRow("Target triangle ratio", () => + { + var targetPercent = (float)(performanceConfig.ModelDecimationTargetRatio * 100.0); + var clampedPercent = Math.Clamp(targetPercent, 60f, 99f); + if (Math.Abs(clampedPercent - targetPercent) > float.Epsilon) + { + performanceConfig.ModelDecimationTargetRatio = clampedPercent / 100.0; + _performanceConfigService.Save(); + targetPercent = clampedPercent; + } + + ImGui.SetNextItemWidth(-1f); + if (ImGui.SliderFloat("##model-decimation-target", ref targetPercent, 60f, 99f, "%.0f%%")) + { + performanceConfig.ModelDecimationTargetRatio = Math.Clamp(targetPercent / 100f, 0.6f, 0.99f); + _performanceConfigService.Save(); + } + }, "Ratio relative to original triangle count (80% keeps 80%). Default: 80%."); + } + } + + ImGui.Dummy(new Vector2(0f, 2f * scale)); + DrawGroupHeader("Behavior & Exceptions", UIColors.Get("LightlessOrange")); + + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + using (var table = ImRaii.Table("model-opt-behavior-table", 3, SettingsTableFlags)) + { + if (table) + { + ImGui.TableSetupColumn("Label", ImGuiTableColumnFlags.WidthFixed, 220f * scale); + ImGui.TableSetupColumn("Control", ImGuiTableColumnFlags.WidthFixed, 180f * scale); + ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + + DrawControlRow("Normalize tangents", () => + { + var normalizeTangents = performanceConfig.ModelDecimationNormalizeTangents; + if (ImGui.Checkbox("##model-normalize-tangents", ref normalizeTangents)) + { + performanceConfig.ModelDecimationNormalizeTangents = normalizeTangents; + _performanceConfigService.Save(); + } + }, "Normalizes tangents to reduce shading artifacts."); + + DrawControlRow("Keep original model files", () => + { + var keepOriginalModels = performanceConfig.KeepOriginalModelFiles; + if (ImGui.Checkbox("##model-keep-original", ref keepOriginalModels)) + { + performanceConfig.KeepOriginalModelFiles = keepOriginalModels; + _performanceConfigService.Save(); + } + }, "Keeps the original model alongside the decimated copy."); + + DrawControlRow("Skip preferred/direct pairs", () => + { + var skipPreferredDecimation = performanceConfig.SkipModelDecimationForPreferredPairs; + if (ImGui.Checkbox("##model-skip-preferred", ref skipPreferredDecimation)) + { + performanceConfig.SkipModelDecimationForPreferredPairs = skipPreferredDecimation; + _performanceConfigService.Save(); + } + }, "Leaves models untouched for preferred/direct pairs."); + } + } + + UiSharedService.ColorTextWrapped( + "Note: Disabling \"Keep original model files\" prevents saved/effective triangle usage information.", + UIColors.Get("LightlessYellow")); + + ImGui.Dummy(new Vector2(0f, 2f * scale)); + DrawGroupHeader("Decimation Targets", UIColors.Get("LightlessGrey"), "Hair mods are always excluded from decimation."); + + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessGreen"), + new SeStringUtils.RichTextEntry("Automatic decimation will only target the selected "), + new SeStringUtils.RichTextEntry("decimation targets", UIColors.Get("LightlessGreen"), true), + new SeStringUtils.RichTextEntry(".")); + + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), + new SeStringUtils.RichTextEntry("It is advised to not decimate any body related meshes which includes: "), + new SeStringUtils.RichTextEntry("facial mods + sculpts, chest, legs, hands and feet", UIColors.Get("LightlessYellow"), true), + new SeStringUtils.RichTextEntry(".")); + + _uiSharedService.DrawNoteLine("!!! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("Automatic decimation is not perfect and can cause meshes with bad topology to be worse.", UIColors.Get("DimRed"), true)); + + DrawTargetGrid(performanceConfig); + + ImGui.Dummy(new Vector2(0f, 4f * scale)); + DrawSummaryPanel("Usage Summary", UIColors.Get("LightlessPurple"), DrawTriangleDecimationCounters); + } + + private void DrawTargetGrid(PlayerPerformanceConfig config) + { + var scale = ImGuiHelpers.GlobalScale; + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + using (var table = ImRaii.Table("model-opt-targets", 3, SettingsTableFlags)) + { + if (!table) + { + return; + } + + ImGui.TableSetupColumn("Label", ImGuiTableColumnFlags.WidthFixed, 220f * scale); + ImGui.TableSetupColumn("Control", ImGuiTableColumnFlags.WidthFixed, 180f * scale); + ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + + DrawControlRow("Body", () => + { + var allowBody = config.ModelDecimationAllowBody; + if (ImGui.Checkbox("##model-target-body", ref allowBody)) + { + config.ModelDecimationAllowBody = allowBody; + _performanceConfigService.Save(); + } + }, "Body meshes (torso, limbs)."); + + DrawControlRow("Face/head", () => + { + var allowFaceHead = config.ModelDecimationAllowFaceHead; + if (ImGui.Checkbox("##model-target-facehead", ref allowFaceHead)) + { + config.ModelDecimationAllowFaceHead = allowFaceHead; + _performanceConfigService.Save(); + } + }, "Face and head meshes."); + + DrawControlRow("Tails/Ears", () => + { + var allowTail = config.ModelDecimationAllowTail; + if (ImGui.Checkbox("##model-target-tail", ref allowTail)) + { + config.ModelDecimationAllowTail = allowTail; + _performanceConfigService.Save(); + } + }, "Tail, ear, and similar appendages."); + + DrawControlRow("Clothing", () => + { + var allowClothing = config.ModelDecimationAllowClothing; + if (ImGui.Checkbox("##model-target-clothing", ref allowClothing)) + { + config.ModelDecimationAllowClothing = allowClothing; + _performanceConfigService.Save(); + } + }, "Outfits, shoes, gloves, hats."); + + DrawControlRow("Accessories", () => + { + var allowAccessories = config.ModelDecimationAllowAccessories; + if (ImGui.Checkbox("##model-target-accessories", ref allowAccessories)) + { + config.ModelDecimationAllowAccessories = allowAccessories; + _performanceConfigService.Save(); + } + }, "Jewelry and small add-ons."); + } + } + + private void DrawSectionIntro(FontAwesomeIcon icon, Vector4 color, string title, string subtitle, bool showTitle) + { + var scale = ImGuiHelpers.GlobalScale; + if (showTitle) + { + using (_uiSharedService.MediumFont.Push()) + { + _uiSharedService.IconText(icon, color); + ImGui.SameLine(0f, 6f * scale); + ImGui.TextColored(color, title); + } + + ImGui.TextColored(UIColors.Get("LightlessGrey"), subtitle); + } + else + { + _uiSharedService.IconText(icon, color); + ImGui.SameLine(0f, 6f * scale); + ImGui.TextColored(UIColors.Get("LightlessGrey"), subtitle); + } + + ImGui.Dummy(new Vector2(0f, 2f * scale)); + } + + private void DrawGroupHeader(string title, Vector4 color, string? helpText = null) + { + using var font = _uiSharedService.MediumFont.Push(); + ImGui.TextColored(color, title); + if (!string.IsNullOrWhiteSpace(helpText)) + { + _uiSharedService.DrawHelpText(helpText); + } + UiSharedService.ColoredSeparator(color, 1.2f); + } + + private void DrawCallout(string id, Vector4 color, Action content) + { + var scale = ImGuiHelpers.GlobalScale; + var bg = new Vector4(color.X, color.Y, color.Z, 0.08f); + var border = new Vector4(color.X, color.Y, color.Z, 0.25f); + DrawPanelBox(id, bg, border, 6f * scale, new Vector2(10f * scale, 6f * scale), content); + } + + private void DrawSummaryPanel(string title, Vector4 accent, Action content) + { + var scale = ImGuiHelpers.GlobalScale; + var bg = new Vector4(accent.X, accent.Y, accent.Z, 0.06f); + var border = new Vector4(accent.X, accent.Y, accent.Z, 0.2f); + DrawPanelBox($"summary-{title}", bg, border, 6f * scale, new Vector2(10f * scale, 6f * scale), () => + { + _uiSharedService.MediumText(title, accent); + content(); + }); + } + + private void DrawTextureCompressionCard(PlayerPerformanceConfig textureConfig) + { + var scale = ImGuiHelpers.GlobalScale; + var baseColor = UIColors.Get("LightlessGrey"); + var bg = new Vector4(baseColor.X, baseColor.Y, baseColor.Z, 0.12f); + var border = new Vector4(baseColor.X, baseColor.Y, baseColor.Z, 0.32f); + + DrawPanelBox("texture-compression-card", bg, border, 6f * scale, new Vector2(10f * scale, 6f * scale), () => + { + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + using (var table = ImRaii.Table("texture-opt-compress-card", 2, SettingsTableFlags)) + { + if (!table) + { + return; + } + + ImGui.TableSetupColumn("Label", ImGuiTableColumnFlags.WidthFixed, 220f * scale); + ImGui.TableSetupColumn("Control", ImGuiTableColumnFlags.WidthStretch); + + DrawInlineDescriptionRow("Compress uncompressed textures", () => + { + var autoCompress = textureConfig.EnableUncompressedTextureCompression; + if (UiSharedService.CheckboxWithBorder("##texture-auto-compress", ref autoCompress, baseColor)) + { + textureConfig.EnableUncompressedTextureCompression = autoCompress; + _performanceConfigService.Save(); + } + }, "Converts uncompressed textures to BC formats based on map type (heavy). Runs after downscale/mip trim.", + drawLabelSuffix: () => + { + _uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow")); + UiSharedService.AttachToolTip("This feature can be demanding and will increase character load times."); + }); + + DrawInlineDescriptionRow("Skip mipmaps for auto-compress", () => + { + var skipMipMaps = textureConfig.SkipUncompressedTextureCompressionMipMaps; + if (UiSharedService.CheckboxWithBorder("##texture-auto-compress-skip-mips", ref skipMipMaps, baseColor)) + { + textureConfig.SkipUncompressedTextureCompressionMipMaps = skipMipMaps; + _performanceConfigService.Save(); + } + }, "Skips mipmap generation to speed up compression, but can cause shimmering.", + disableControl: !textureConfig.EnableUncompressedTextureCompression); + } + }); + } + + private void DrawInlineDescriptionRow( + string label, + Action drawControl, + string description, + Action? drawLabelSuffix = null, + bool disableControl = false) + { + var scale = ImGuiHelpers.GlobalScale; + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(label); + if (drawLabelSuffix != null) + { + ImGui.SameLine(0f, 4f * scale); + drawLabelSuffix(); + } + + ImGui.TableSetColumnIndex(1); + using (ImRaii.Disabled(disableControl)) + { + drawControl(); + } + + ImGui.SameLine(0f, 8f * scale); + using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("LightlessGrey"))) + { + ImGui.PushTextWrapPos(ImGui.GetCursorPos().X + ImGui.GetContentRegionAvail().X); + ImGui.TextUnformatted(description); + ImGui.PopTextWrapPos(); + } + } + + private void DrawControlRow(string label, Action drawControl, string description, Vector4? labelColor = null, Vector4? cardAccent = null, Action? drawLabelSuffix = null) + { + var scale = ImGuiHelpers.GlobalScale; + if (!cardAccent.HasValue) + { + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + ImGui.AlignTextToFramePadding(); + using var labelTint = ImRaii.PushColor(ImGuiCol.Text, labelColor ?? Vector4.Zero, labelColor.HasValue); + ImGui.TextUnformatted(label); + if (drawLabelSuffix != null) + { + ImGui.SameLine(0f, 4f * scale); + drawLabelSuffix(); + } + ImGui.TableSetColumnIndex(1); + drawControl(); + ImGui.TableSetColumnIndex(2); + using var color = ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("LightlessGrey")); + ImGui.TextWrapped(description); + return; + } + + var padX = 6f * scale; + var padY = 3f * scale; + var rowGap = 4f * scale; + var accent = cardAccent.Value; + var drawList = ImGui.GetWindowDrawList(); + + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + var col0Start = ImGui.GetCursorScreenPos(); + ImGui.TableSetColumnIndex(1); + var col1Start = ImGui.GetCursorScreenPos(); + ImGui.TableSetColumnIndex(2); + var col2Start = ImGui.GetCursorScreenPos(); + var col2Width = ImGui.GetContentRegionAvail().X; + + float descriptionHeight; + using (ImRaii.PushColor(ImGuiCol.Text, new Vector4(0f, 0f, 0f, 0f))) + { + ImGui.SetCursorScreenPos(col2Start); + ImGui.PushTextWrapPos(ImGui.GetCursorPos().X + col2Width); + ImGui.TextUnformatted(description); + ImGui.PopTextWrapPos(); + descriptionHeight = ImGui.GetItemRectSize().Y; + } + + var lineHeight = ImGui.GetTextLineHeight(); + var labelHeight = lineHeight; + var controlHeight = ImGui.GetFrameHeight(); + var contentHeight = MathF.Max(labelHeight, MathF.Max(controlHeight, descriptionHeight)); + var lineCount = Math.Max(1, (int)MathF.Round(descriptionHeight / MathF.Max(1f, lineHeight))); + var descOffset = lineCount > 1 ? lineHeight * 0.18f : 0f; + var cardTop = col0Start.Y; + var contentTop = cardTop + padY; + var cardHeight = contentHeight + (padY * 2f); + + var labelY = contentTop + (contentHeight - labelHeight) * 0.5f; + var controlY = contentTop + (contentHeight - controlHeight) * 0.5f; + var descY = contentTop + (contentHeight - descriptionHeight) * 0.5f - descOffset; + + drawList.ChannelsSplit(2); + drawList.ChannelsSetCurrent(1); + + ImGui.TableSetColumnIndex(0); + ImGui.SetCursorScreenPos(new Vector2(col0Start.X, labelY)); + using (ImRaii.PushColor(ImGuiCol.Text, labelColor ?? Vector4.Zero, labelColor.HasValue)) + { + ImGui.TextUnformatted(label); + if (drawLabelSuffix != null) + { + ImGui.SameLine(0f, 4f * scale); + drawLabelSuffix(); + } + } + + ImGui.TableSetColumnIndex(1); + ImGui.SetCursorScreenPos(new Vector2(col1Start.X, controlY)); + drawControl(); + + ImGui.TableSetColumnIndex(2); + ImGui.SetCursorScreenPos(new Vector2(col2Start.X, descY)); + using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("LightlessGrey"))) + { + ImGui.PushTextWrapPos(ImGui.GetCursorPos().X + col2Width); + ImGui.TextUnformatted(description); + ImGui.PopTextWrapPos(); + } + + var rectMin = new Vector2(col0Start.X - padX, cardTop); + var rectMax = new Vector2(col2Start.X + col2Width + padX, cardTop + cardHeight); + var fill = new Vector4(accent.X, accent.Y, accent.Z, 0.07f); + var border = new Vector4(accent.X, accent.Y, accent.Z, 0.35f); + var rounding = MathF.Max(5f, ImGui.GetStyle().FrameRounding) * scale; + var borderThickness = MathF.Max(1f, ImGui.GetStyle().ChildBorderSize); + var clipMin = drawList.GetClipRectMin(); + var clipMax = drawList.GetClipRectMax(); + clipMin.X = MathF.Min(clipMin.X, rectMin.X); + clipMax.X = MathF.Max(clipMax.X, rectMax.X); + + drawList.ChannelsSetCurrent(0); + drawList.PushClipRect(clipMin, clipMax, false); + drawList.AddRectFilled(rectMin, rectMax, UiSharedService.Color(fill), rounding); + drawList.AddRect(rectMin, rectMax, UiSharedService.Color(border), rounding, ImDrawFlags.None, borderThickness); + drawList.PopClipRect(); + drawList.ChannelsMerge(); + + ImGui.TableSetColumnIndex(2); + ImGui.SetCursorScreenPos(new Vector2(col2Start.X, cardTop + cardHeight)); + ImGui.Dummy(new Vector2(0f, rowGap)); + } + + private static bool DrawAccentCheckbox(string id, ref bool value, Vector4 accent) + { + var frame = new Vector4(accent.X, accent.Y, accent.Z, 0.14f); + var frameHovered = new Vector4(accent.X, accent.Y, accent.Z, 0.22f); + var frameActive = new Vector4(accent.X, accent.Y, accent.Z, 0.3f); + bool changed; + using (ImRaii.PushColor(ImGuiCol.CheckMark, accent)) + using (ImRaii.PushColor(ImGuiCol.FrameBg, frame)) + using (ImRaii.PushColor(ImGuiCol.FrameBgHovered, frameHovered)) + using (ImRaii.PushColor(ImGuiCol.FrameBgActive, frameActive)) + { + changed = ImGui.Checkbox(id, ref value); + } + return changed; + } + + private static void DrawPanelBox(string id, Vector4 background, Vector4 border, float rounding, Vector2 padding, Action content) + { + using (ImRaii.PushId(id)) + { + var startPos = ImGui.GetCursorScreenPos(); + var availableWidth = ImGui.GetContentRegionAvail().X; + var drawList = ImGui.GetWindowDrawList(); + + drawList.ChannelsSplit(2); + drawList.ChannelsSetCurrent(1); + + using (ImRaii.Group()) + { + ImGui.Dummy(new Vector2(0f, padding.Y)); + ImGui.Indent(padding.X); + content(); + ImGui.Unindent(padding.X); + ImGui.Dummy(new Vector2(0f, padding.Y)); + } + + var rectMin = startPos; + var rectMax = new Vector2(startPos.X + availableWidth, ImGui.GetItemRectMax().Y); + var borderThickness = MathF.Max(1f, ImGui.GetStyle().ChildBorderSize); + + drawList.ChannelsSetCurrent(0); + drawList.AddRectFilled(rectMin, rectMax, UiSharedService.Color(background), rounding); + drawList.AddRect(rectMin, rectMax, UiSharedService.Color(border), rounding, ImDrawFlags.None, borderThickness); + drawList.ChannelsMerge(); + } + } + + private void DrawTextureDownscaleCounters() + { + HashSet trackedPairs = new(); + + var snapshot = _pairUiService.GetSnapshot(); + + foreach (var pair in snapshot.DirectPairs) + { + trackedPairs.Add(pair); + } + + foreach (var group in snapshot.GroupPairs.Values) + { + foreach (var pair in group) + { + trackedPairs.Add(pair); + } + } + + long totalOriginalBytes = 0; + long totalEffectiveBytes = 0; + var hasData = false; + + foreach (var pair in trackedPairs) + { + if (!pair.IsVisible) + continue; + + var original = pair.LastAppliedApproximateVRAMBytes; + var effective = pair.LastAppliedApproximateEffectiveVRAMBytes; + + if (original >= 0) + { + hasData = true; + totalOriginalBytes += original; + } + + if (effective >= 0) + { + hasData = true; + totalEffectiveBytes += effective; + } + } + + if (!hasData) + { + ImGui.TextDisabled("VRAM usage has not been calculated yet."); + return; + } + + var savedBytes = Math.Max(0L, totalOriginalBytes - totalEffectiveBytes); + var originalText = UiSharedService.ByteToString(totalOriginalBytes, addSuffix: true); + var effectiveText = UiSharedService.ByteToString(totalEffectiveBytes, addSuffix: true); + var savedText = UiSharedService.ByteToString(savedBytes, addSuffix: true); + + ImGui.TextUnformatted($"Total VRAM usage (original): {originalText}"); + ImGui.TextUnformatted($"Total VRAM usage (effective): {effectiveText}"); + + if (savedBytes > 0) + { + UiSharedService.ColorText($"VRAM saved by downscaling: {savedText}", UIColors.Get("LightlessGreen")); + } + else + { + ImGui.TextUnformatted($"VRAM saved by downscaling: {savedText}"); + } + } + + private void DrawTriangleDecimationCounters() + { + HashSet trackedPairs = new(); + + var snapshot = _pairUiService.GetSnapshot(); + + foreach (var pair in snapshot.DirectPairs) + { + trackedPairs.Add(pair); + } + + foreach (var group in snapshot.GroupPairs.Values) + { + foreach (var pair in group) + { + trackedPairs.Add(pair); + } + } + + long totalOriginalTris = 0; + long totalEffectiveTris = 0; + var hasData = false; + + foreach (var pair in trackedPairs) + { + if (!pair.IsVisible) + continue; + + var original = pair.LastAppliedDataTris; + var effective = pair.LastAppliedApproximateEffectiveTris; + + if (original >= 0) + { + hasData = true; + totalOriginalTris += original; + } + + if (effective >= 0) + { + hasData = true; + totalEffectiveTris += effective; + } + } + + if (!hasData) + { + ImGui.TextDisabled("Triangle usage has not been calculated yet."); + return; + } + + var savedTris = Math.Max(0L, totalOriginalTris - totalEffectiveTris); + var originalText = FormatTriangleCount(totalOriginalTris); + var effectiveText = FormatTriangleCount(totalEffectiveTris); + var savedText = FormatTriangleCount(savedTris); + + ImGui.TextUnformatted($"Total triangle usage (original): {originalText}"); + ImGui.TextUnformatted($"Total triangle usage (effective): {effectiveText}"); + + if (savedTris > 0) + { + UiSharedService.ColorText($"Triangles saved by decimation: {savedText}", UIColors.Get("LightlessGreen")); + } + else + { + ImGui.TextUnformatted($"Triangles saved by decimation: {savedText}"); + } + } + + private static string FormatTriangleCount(long triangleCount) + { + if (triangleCount < 0) + { + return "n/a"; + } + + if (triangleCount >= 1_000_000) + { + return FormattableString.Invariant($"{triangleCount / 1_000_000d:0.#}m tris"); + } + + if (triangleCount >= 1_000) + { + return FormattableString.Invariant($"{triangleCount / 1_000d:0.#}k tris"); + } + + return $"{triangleCount} tris"; + } +} diff --git a/LightlessSync/UI/Components/OptimizationSummaryCard.cs b/LightlessSync/UI/Components/OptimizationSummaryCard.cs new file mode 100644 index 0000000..62c0bc0 --- /dev/null +++ b/LightlessSync/UI/Components/OptimizationSummaryCard.cs @@ -0,0 +1,789 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using LightlessSync.LightlessConfiguration; +using LightlessSync.LightlessConfiguration.Configurations; +using LightlessSync.PlayerData.Pairs; +using LightlessSync.Services.Mediator; +using LightlessSync.UI.Services; +using LightlessSync.UI.Style; +using LightlessSync.WebAPI.Files; +using System.Globalization; +using System.Numerics; +using System.Runtime.InteropServices; + +namespace LightlessSync.UI.Components; + +public sealed class OptimizationSummaryCard +{ + private readonly UiSharedService _uiSharedService; + private readonly PairUiService _pairUiService; + private readonly PlayerPerformanceConfigService _playerPerformanceConfig; + private readonly FileUploadManager _fileTransferManager; + private readonly LightlessMediator _lightlessMediator; + private readonly OptimizationSettingsPanel _optimizationSettingsPanel; + private readonly SeluneBrush _optimizationBrush = new(); + private const string OptimizationPopupId = "Optimization Settings##LightlessOptimization"; + private bool _optimizationPopupOpen; + private bool _optimizationPopupRequest; + private OptimizationPanelSection _optimizationPopupSection = OptimizationPanelSection.Texture; + + public OptimizationSummaryCard( + UiSharedService uiSharedService, + PairUiService pairUiService, + PlayerPerformanceConfigService playerPerformanceConfig, + FileUploadManager fileTransferManager, + LightlessMediator lightlessMediator) + { + _uiSharedService = uiSharedService; + _pairUiService = pairUiService; + _playerPerformanceConfig = playerPerformanceConfig; + _fileTransferManager = fileTransferManager; + _lightlessMediator = lightlessMediator; + _optimizationSettingsPanel = new OptimizationSettingsPanel(uiSharedService, playerPerformanceConfig, pairUiService); + } + + public bool Draw(int activeDownloads) + { + var totals = GetPerformanceTotals(); + var scale = ImGuiHelpers.GlobalScale; + var accent = UIColors.Get("LightlessPurple"); + var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.04f); + var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.16f); + var summaryPadding = new Vector2(12f * scale, 6f * scale); + var summaryItemSpacing = new Vector2(12f * scale, 4f * scale); + var cellPadding = new Vector2(6f * scale, 2f * scale); + var lineHeight = ImGui.GetFrameHeight(); + var lineSpacing = summaryItemSpacing.Y; + var statsContentHeight = (lineHeight * 2f) + lineSpacing; + var summaryHeight = MathF.Max(56f * scale, statsContentHeight + (summaryPadding.Y * 2f) + (cellPadding.Y * 2f)); + var activeUploads = _fileTransferManager.GetCurrentUploadsSnapshot().Count(upload => !upload.IsTransferred); + + var textureButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Images); + var modelButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.ProjectDiagram); + var buttonWidth = MathF.Max(textureButtonSize.X, modelButtonSize.X); + var performanceConfig = _playerPerformanceConfig.Current; + var textureStatus = GetTextureOptimizationStatus(performanceConfig); + var modelStatus = GetModelOptimizationStatus(performanceConfig); + var textureStatusVisual = GetOptimizationStatusVisual(textureStatus); + var modelStatusVisual = GetOptimizationStatusVisual(modelStatus); + var textureStatusLines = BuildTextureOptimizationStatusLines(performanceConfig); + var modelStatusLines = BuildModelOptimizationStatusLines(performanceConfig); + var statusIconSpacing = 6f * scale; + var statusIconWidth = MathF.Max(GetIconWidth(textureStatusVisual.Icon), GetIconWidth(modelStatusVisual.Icon)); + var buttonRowWidth = buttonWidth + statusIconWidth + statusIconSpacing; + var vramValue = totals.HasVramData + ? UiSharedService.ByteToString(totals.DisplayVramBytes, addSuffix: true) + : "n/a"; + var vramTooltip = BuildVramTooltipData(totals, UIColors.Get("LightlessBlue")); + var triangleValue = totals.HasTriangleData + ? FormatTriangleCount(totals.DisplayTriangleCount) + : "n/a"; + var triangleTooltip = BuildTriangleTooltipData(totals, UIColors.Get("LightlessPurple")); + + var windowPos = ImGui.GetWindowPos(); + var windowSize = ImGui.GetWindowSize(); + var footerTop = ImGui.GetCursorScreenPos().Y; + var gradientTop = MathF.Max(windowPos.Y, footerTop - (12f * scale)); + var gradientBottom = windowPos.Y + windowSize.Y; + var footerSettings = new SeluneGradientSettings + { + GradientColor = UIColors.Get("LightlessPurple"), + GradientPeakOpacity = 0.08f, + GradientPeakPosition = 0.18f, + BackgroundMode = SeluneGradientMode.Vertical, + }; + using var footerSelune = Selune.Begin(_optimizationBrush, ImGui.GetWindowDrawList(), windowPos, windowSize, footerSettings); + footerSelune.DrawGradient(gradientTop, gradientBottom, ImGui.GetIO().DeltaTime); + + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize))) + using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, summaryPadding)) + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, summaryItemSpacing)) + using (ImRaii.PushColor(ImGuiCol.ChildBg, UiSharedService.Color(accentBg))) + using (ImRaii.PushColor(ImGuiCol.Border, UiSharedService.Color(accentBorder))) + using (var child = ImRaii.Child("optimizationSummary", new Vector2(-1f, summaryHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) + { + if (child) + { + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, cellPadding)) + { + if (ImGui.BeginTable("optimizationSummaryTable", 2, ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.NoBordersInBody)) + { + ImGui.TableSetupColumn("Stats", ImGuiTableColumnFlags.WidthStretch, 1f); + ImGui.TableSetupColumn("Button", ImGuiTableColumnFlags.WidthFixed, buttonRowWidth + 12f * scale); + + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + var availableHeight = summaryHeight - (summaryPadding.Y * 2f) - (cellPadding.Y * 2f); + var verticalPad = MathF.Max(0f, (availableHeight - statsContentHeight) * 0.5f); + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(summaryItemSpacing.X, 0f))) + { + if (verticalPad > 0f) + { + ImGui.Dummy(new Vector2(0f, verticalPad)); + } + DrawOptimizationStatLine(FontAwesomeIcon.Memory, UIColors.Get("LightlessBlue"), "VRAM usage", vramValue, vramTooltip, scale); + if (lineSpacing > 0f) + { + ImGui.Dummy(new Vector2(0f, lineSpacing)); + } + DrawOptimizationStatLine(FontAwesomeIcon.ProjectDiagram, UIColors.Get("LightlessPurple"), "Triangles", triangleValue, triangleTooltip, scale); + if (verticalPad > 0f) + { + ImGui.Dummy(new Vector2(0f, verticalPad)); + } + } + + ImGui.TableNextColumn(); + var separatorX = ImGui.GetCursorScreenPos().X - cellPadding.X; + var separatorTop = ImGui.GetWindowPos().Y + summaryPadding.Y; + var separatorBottom = ImGui.GetWindowPos().Y + summaryHeight - summaryPadding.Y; + ImGui.GetWindowDrawList().AddLine( + new Vector2(separatorX, separatorTop), + new Vector2(separatorX, separatorBottom), + ImGui.ColorConvertFloat4ToU32(accentBorder), + MathF.Max(1f, 1f * scale)); + float cellWidth = ImGui.GetContentRegionAvail().X; + float offsetX = MathF.Max(0f, cellWidth - buttonRowWidth); + float alignedX = ImGui.GetCursorPosX() + offsetX; + + using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 6f * scale)) + using (ImRaii.PushColor(ImGuiCol.Button, ImGui.ColorConvertFloat4ToU32(new Vector4(0f, 0f, 0f, 0f)))) + { + var buttonBorderThickness = 10f * scale; + var buttonRounding = ImGui.GetStyle().FrameRounding; + + DrawOptimizationStatusButtonRow( + "Texture Optimization", + textureStatusVisual.Icon, + textureStatusVisual.Color, + textureStatusVisual.Label, + textureStatusLines, + FontAwesomeIcon.Images, + textureButtonSize, + "Texture Optimization", + activeUploads, + activeDownloads, + () => OpenOptimizationPopup(OptimizationPanelSection.Texture), + alignedX, + statusIconSpacing, + buttonBorderThickness, + buttonRounding); + + DrawOptimizationStatusButtonRow( + "Model Optimization", + modelStatusVisual.Icon, + modelStatusVisual.Color, + modelStatusVisual.Label, + modelStatusLines, + FontAwesomeIcon.ProjectDiagram, + modelButtonSize, + "Model Optimization", + activeUploads, + activeDownloads, + () => OpenOptimizationPopup(OptimizationPanelSection.Model), + alignedX, + statusIconSpacing, + buttonBorderThickness, + buttonRounding); + } + + ImGui.EndTable(); + } + } + } + } + + footerSelune.DrawHighlightOnly(gradientTop, gradientBottom, ImGui.GetIO().DeltaTime); + DrawOptimizationPopup(); + return true; + } + + private PerformanceTotals GetPerformanceTotals() + { + HashSet trackedPairs = new(); + + var snapshot = _pairUiService.GetSnapshot(); + + foreach (var pair in snapshot.DirectPairs) + { + trackedPairs.Add(pair); + } + + foreach (var group in snapshot.GroupPairs.Values) + { + foreach (var pair in group) + { + trackedPairs.Add(pair); + } + } + + long displayVramBytes = 0; + long originalVramBytes = 0; + long effectiveVramBytes = 0; + bool hasVramData = false; + bool hasOriginalVram = false; + bool hasEffectiveVram = false; + + long displayTriangles = 0; + long originalTriangles = 0; + long effectiveTriangles = 0; + bool hasTriangleData = false; + bool hasOriginalTriangles = false; + bool hasEffectiveTriangles = false; + + foreach (var pair in trackedPairs) + { + if (!pair.IsVisible) + { + continue; + } + + var originalVram = pair.LastAppliedApproximateVRAMBytes; + var effectiveVram = pair.LastAppliedApproximateEffectiveVRAMBytes; + + if (originalVram >= 0) + { + originalVramBytes += originalVram; + hasOriginalVram = true; + } + + if (effectiveVram >= 0) + { + effectiveVramBytes += effectiveVram; + hasEffectiveVram = true; + } + + if (effectiveVram >= 0) + { + displayVramBytes += effectiveVram; + hasVramData = true; + } + else if (originalVram >= 0) + { + displayVramBytes += originalVram; + hasVramData = true; + } + + var originalTris = pair.LastAppliedDataTris; + var effectiveTris = pair.LastAppliedApproximateEffectiveTris; + + if (originalTris >= 0) + { + originalTriangles += originalTris; + hasOriginalTriangles = true; + } + + if (effectiveTris >= 0) + { + effectiveTriangles += effectiveTris; + hasEffectiveTriangles = true; + } + + if (effectiveTris >= 0) + { + displayTriangles += effectiveTris; + hasTriangleData = true; + } + else if (originalTris >= 0) + { + displayTriangles += originalTris; + hasTriangleData = true; + } + } + + return new PerformanceTotals( + displayVramBytes, + originalVramBytes, + effectiveVramBytes, + displayTriangles, + originalTriangles, + effectiveTriangles, + hasVramData, + hasOriginalVram, + hasEffectiveVram, + hasTriangleData, + hasOriginalTriangles, + hasEffectiveTriangles); + } + + private void DrawOptimizationStatLine(FontAwesomeIcon icon, Vector4 iconColor, string label, string value, OptimizationStatTooltip? tooltip, float scale) + { + ImGui.AlignTextToFramePadding(); + _uiSharedService.IconText(icon, iconColor); + var hovered = ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem); + ImGui.SameLine(0f, 6f * scale); + ImGui.TextUnformatted($"{label}: {value}"); + hovered |= ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem); + if (hovered && tooltip.HasValue) + { + DrawOptimizationStatTooltip(tooltip.Value); + } + } + + private static OptimizationStatTooltip? BuildVramTooltipData(PerformanceTotals totals, Vector4 titleColor) + { + if (!totals.HasOriginalVram && !totals.HasEffectiveVram) + { + return null; + } + + var lines = new List(); + + if (totals.HasOriginalVram) + { + lines.Add(new OptimizationTooltipLine( + "Original", + UiSharedService.ByteToString(totals.OriginalVramBytes, addSuffix: true), + UIColors.Get("LightlessYellow"))); + } + + if (totals.HasEffectiveVram) + { + lines.Add(new OptimizationTooltipLine( + "Effective", + UiSharedService.ByteToString(totals.EffectiveVramBytes, addSuffix: true), + UIColors.Get("LightlessGreen"))); + } + + if (totals.HasOriginalVram && totals.HasEffectiveVram) + { + var savedBytes = Math.Max(0L, totals.OriginalVramBytes - totals.EffectiveVramBytes); + if (savedBytes > 0) + { + lines.Add(new OptimizationTooltipLine( + "Saved", + UiSharedService.ByteToString(savedBytes, addSuffix: true), + titleColor)); + } + } + + return new OptimizationStatTooltip( + "Total VRAM usage", + "Approximate texture memory across visible users.", + titleColor, + lines); + } + + private static OptimizationStatTooltip? BuildTriangleTooltipData(PerformanceTotals totals, Vector4 titleColor) + { + if (!totals.HasOriginalTriangles && !totals.HasEffectiveTriangles) + { + return null; + } + + var lines = new List(); + + if (totals.HasOriginalTriangles) + { + lines.Add(new OptimizationTooltipLine( + "Original", + $"{FormatTriangleCount(totals.OriginalTriangleCount)} tris", + UIColors.Get("LightlessYellow"))); + } + + if (totals.HasEffectiveTriangles) + { + lines.Add(new OptimizationTooltipLine( + "Effective", + $"{FormatTriangleCount(totals.EffectiveTriangleCount)} tris", + UIColors.Get("LightlessGreen"))); + } + + if (totals.HasOriginalTriangles && totals.HasEffectiveTriangles) + { + var savedTris = Math.Max(0L, totals.OriginalTriangleCount - totals.EffectiveTriangleCount); + if (savedTris > 0) + { + lines.Add(new OptimizationTooltipLine( + "Saved", + $"{FormatTriangleCount(savedTris)} tris", + titleColor)); + } + } + + return new OptimizationStatTooltip( + "Total triangles", + "Approximate triangle count across visible users.", + titleColor, + lines); + } + + private static string FormatTriangleCount(long triangleCount) + { + if (triangleCount < 0) + { + return "n/a"; + } + + if (triangleCount >= 1_000_000) + { + return FormattableString.Invariant($"{triangleCount / 1_000_000d:0.#}m"); + } + + if (triangleCount >= 1_000) + { + return FormattableString.Invariant($"{triangleCount / 1_000d:0.#}k"); + } + + return triangleCount.ToString(CultureInfo.InvariantCulture); + } + + private enum OptimizationStatus + { + Off, + Partial, + On, + } + + private static OptimizationStatus GetTextureOptimizationStatus(PlayerPerformanceConfig config) + { + bool trimEnabled = config.EnableNonIndexTextureMipTrim; + bool downscaleEnabled = config.EnableIndexTextureDownscale; + + if (!trimEnabled && !downscaleEnabled) + { + return OptimizationStatus.Off; + } + + return trimEnabled && downscaleEnabled + ? OptimizationStatus.On + : OptimizationStatus.Partial; + } + + private static OptimizationStatus GetModelOptimizationStatus(PlayerPerformanceConfig config) + { + if (!config.EnableModelDecimation) + { + return OptimizationStatus.Off; + } + + bool hasTargets = config.ModelDecimationAllowBody + || config.ModelDecimationAllowFaceHead + || config.ModelDecimationAllowTail + || config.ModelDecimationAllowClothing + || config.ModelDecimationAllowAccessories; + + return hasTargets + ? OptimizationStatus.On + : OptimizationStatus.Partial; + } + + private static (FontAwesomeIcon Icon, Vector4 Color, string Label) GetOptimizationStatusVisual(OptimizationStatus status) + { + return status switch + { + OptimizationStatus.On => (FontAwesomeIcon.Check, UIColors.Get("LightlessGreen"), "Enabled"), + OptimizationStatus.Partial => (FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow"), "Partial"), + _ => (FontAwesomeIcon.Times, UIColors.Get("DimRed"), "Disabled"), + }; + } + + private static OptimizationTooltipLine[] BuildTextureOptimizationStatusLines(PlayerPerformanceConfig config) + { + return + [ + new OptimizationTooltipLine("Trim mip levels", FormatOnOff(config.EnableNonIndexTextureMipTrim), GetOnOffColor(config.EnableNonIndexTextureMipTrim)), + new OptimizationTooltipLine("Downscale index textures", FormatOnOff(config.EnableIndexTextureDownscale), GetOnOffColor(config.EnableIndexTextureDownscale)), + new OptimizationTooltipLine("Max dimension", config.TextureDownscaleMaxDimension.ToString(CultureInfo.InvariantCulture)), + new OptimizationTooltipLine("Only downscale uncompressed", FormatOnOff(config.OnlyDownscaleUncompressedTextures), GetOnOffColor(config.OnlyDownscaleUncompressedTextures)), + new OptimizationTooltipLine("Compress uncompressed textures", FormatOnOff(config.EnableUncompressedTextureCompression), GetOnOffColor(config.EnableUncompressedTextureCompression)), + new OptimizationTooltipLine("Skip auto-compress mipmaps", FormatOnOff(config.SkipUncompressedTextureCompressionMipMaps), GetOnOffColor(config.SkipUncompressedTextureCompressionMipMaps)), + new OptimizationTooltipLine("Keep original textures", FormatOnOff(config.KeepOriginalTextureFiles), GetOnOffColor(config.KeepOriginalTextureFiles)), + new OptimizationTooltipLine("Skip preferred pairs", FormatOnOff(config.SkipTextureDownscaleForPreferredPairs), GetOnOffColor(config.SkipTextureDownscaleForPreferredPairs)), + ]; + } + + private static OptimizationTooltipLine[] BuildModelOptimizationStatusLines(PlayerPerformanceConfig config) + { + var targets = new List(); + if (config.ModelDecimationAllowBody) + { + targets.Add("Body"); + } + + if (config.ModelDecimationAllowFaceHead) + { + targets.Add("Face/head"); + } + + if (config.ModelDecimationAllowTail) + { + targets.Add("Tails/Ears"); + } + + if (config.ModelDecimationAllowClothing) + { + targets.Add("Clothing"); + } + + if (config.ModelDecimationAllowAccessories) + { + targets.Add("Accessories"); + } + + var targetLabel = targets.Count > 0 ? string.Join(", ", targets) : "None"; + var targetColor = targets.Count > 0 ? UIColors.Get("LightlessGreen") : UIColors.Get("DimRed"); + var threshold = config.ModelDecimationTriangleThreshold.ToString("N0", CultureInfo.InvariantCulture); + var targetRatio = FormatPercent(config.ModelDecimationTargetRatio); + + return + [ + new OptimizationTooltipLine("Decimation enabled", FormatOnOff(config.EnableModelDecimation), GetOnOffColor(config.EnableModelDecimation)), + new OptimizationTooltipLine("Triangle threshold", threshold), + new OptimizationTooltipLine("Target ratio", targetRatio), + new OptimizationTooltipLine("Normalize tangents", FormatOnOff(config.ModelDecimationNormalizeTangents), GetOnOffColor(config.ModelDecimationNormalizeTangents)), + new OptimizationTooltipLine("Keep original models", FormatOnOff(config.KeepOriginalModelFiles), GetOnOffColor(config.KeepOriginalModelFiles)), + new OptimizationTooltipLine("Skip preferred pairs", FormatOnOff(config.SkipModelDecimationForPreferredPairs), GetOnOffColor(config.SkipModelDecimationForPreferredPairs)), + new OptimizationTooltipLine("Targets", targetLabel, targetColor), + ]; + } + + private static string FormatOnOff(bool value) + => value ? "On" : "Off"; + + private static string FormatPercent(double value) + => FormattableString.Invariant($"{value * 100d:0.#}%"); + + private static Vector4 GetOnOffColor(bool value) + => value ? UIColors.Get("LightlessGreen") : UIColors.Get("DimRed"); + + private static float GetIconWidth(FontAwesomeIcon icon) + { + using var iconFont = ImRaii.PushFont(UiBuilder.IconFont); + return ImGui.CalcTextSize(icon.ToIconString()).X; + } + + private readonly record struct OptimizationStatTooltip(string Title, string Description, Vector4 TitleColor, IReadOnlyList Lines); + + private static void DrawOptimizationStatTooltip(OptimizationStatTooltip tooltip) + { + ImGui.BeginTooltip(); + ImGui.PushTextWrapPos(ImGui.GetFontSize() * 32f); + + ImGui.TextColored(tooltip.TitleColor, tooltip.Title); + ImGui.TextColored(UIColors.Get("LightlessGrey"), tooltip.Description); + + foreach (var line in tooltip.Lines) + { + ImGui.TextUnformatted($"{line.Label}:"); + ImGui.SameLine(); + if (line.ValueColor.HasValue) + { + ImGui.TextColored(line.ValueColor.Value, line.Value); + } + else + { + ImGui.TextUnformatted(line.Value); + } + } + + ImGui.PopTextWrapPos(); + ImGui.EndTooltip(); + } + + private static void DrawOptimizationButtonTooltip(string title, int activeUploads, int activeDownloads) + { + ImGui.BeginTooltip(); + ImGui.PushTextWrapPos(ImGui.GetFontSize() * 32f); + + ImGui.TextColored(UIColors.Get("LightlessPurple"), title); + ImGui.TextColored(UIColors.Get("LightlessGrey"), "Open optimization settings."); + + if (activeUploads > 0 || activeDownloads > 0) + { + ImGui.Separator(); + ImGui.TextUnformatted($"Active uploads: {activeUploads}"); + ImGui.TextUnformatted($"Active downloads: {activeDownloads}"); + } + + ImGui.PopTextWrapPos(); + ImGui.EndTooltip(); + } + + private readonly record struct OptimizationTooltipLine(string Label, string Value, Vector4? ValueColor = null); + + private static void DrawOptimizationStatusTooltip(string title, string statusLabel, Vector4 statusColor, IReadOnlyList lines) + { + ImGui.BeginTooltip(); + ImGui.PushTextWrapPos(ImGui.GetFontSize() * 32f); + + ImGui.TextColored(UIColors.Get("LightlessPurple"), title); + ImGui.TextUnformatted("Status:"); + ImGui.SameLine(); + ImGui.TextColored(statusColor, statusLabel); + + foreach (var line in lines) + { + ImGui.TextUnformatted($"{line.Label}:"); + ImGui.SameLine(); + if (line.ValueColor.HasValue) + { + ImGui.TextColored(line.ValueColor.Value, line.Value); + } + else + { + ImGui.TextUnformatted(line.Value); + } + } + + ImGui.PopTextWrapPos(); + ImGui.EndTooltip(); + } + + private void DrawOptimizationStatusButtonRow( + string statusTitle, + FontAwesomeIcon statusIcon, + Vector4 statusColor, + string statusLabel, + IReadOnlyList statusLines, + FontAwesomeIcon buttonIcon, + Vector2 buttonSize, + string tooltipTitle, + int activeUploads, + int activeDownloads, + Action openPopup, + float alignedX, + float iconSpacing, + float buttonBorderThickness, + float buttonRounding) + { + ImGui.SetCursorPosX(alignedX); + ImGui.AlignTextToFramePadding(); + _uiSharedService.IconText(statusIcon, statusColor); + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem)) + { + DrawOptimizationStatusTooltip(statusTitle, statusLabel, statusColor, statusLines); + } + + ImGui.SameLine(0f, iconSpacing); + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + if (ImGui.Button(buttonIcon.ToIconString(), buttonSize)) + { + openPopup(); + } + } + + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) + { + Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); + } + + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem)) + { + DrawOptimizationButtonTooltip(tooltipTitle, activeUploads, activeDownloads); + } + } + + private void OpenOptimizationPopup(OptimizationPanelSection section) + { + _optimizationPopupSection = section; + _optimizationPopupOpen = true; + _optimizationPopupRequest = true; + } + + private void DrawOptimizationPopup() + { + if (!_optimizationPopupOpen) + { + return; + } + + if (_optimizationPopupRequest) + { + ImGui.OpenPopup(OptimizationPopupId); + _optimizationPopupRequest = false; + } + + var scale = ImGuiHelpers.GlobalScale; + ImGui.SetNextWindowSize(new Vector2(680f * scale, 640f * scale), ImGuiCond.Appearing); + + if (ImGui.BeginPopupModal(OptimizationPopupId, ref _optimizationPopupOpen, UiSharedService.PopupWindowFlags)) + { + DrawOptimizationPopupHeader(); + ImGui.Separator(); + ImGui.Dummy(new Vector2(0f, 4f * scale)); + using (var child = ImRaii.Child("optimization-popup-body", new Vector2(0f, 0f), false, ImGuiWindowFlags.AlwaysVerticalScrollbar)) + { + if (child) + { + _optimizationSettingsPanel.DrawPopup(_optimizationPopupSection); + } + } + + ImGui.EndPopup(); + } + } + + private void DrawOptimizationPopupHeader() + { + var scale = ImGuiHelpers.GlobalScale; + var (title, icon, color, section) = GetPopupHeaderData(_optimizationPopupSection); + var settingsButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Cog); + using (var table = ImRaii.Table("optimization-popup-header", 2, ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.NoBordersInBody)) + { + if (!table) + { + return; + } + + ImGui.TableSetupColumn("Title", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Settings", ImGuiTableColumnFlags.WidthFixed, settingsButtonSize.X); + + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + using (_uiSharedService.MediumFont.Push()) + { + _uiSharedService.IconText(icon, color); + ImGui.SameLine(0f, 6f * scale); + ImGui.TextColored(color, title); + } + + ImGui.TableNextColumn(); + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + if (ImGui.Button(FontAwesomeIcon.Cog.ToIconString(), settingsButtonSize)) + { + OpenOptimizationSettings(section); + } + } + + UiSharedService.AttachToolTip("Open this section in Settings."); + } + } + + private void OpenOptimizationSettings(OptimizationPanelSection section) + { + var target = section == OptimizationPanelSection.Texture + ? PerformanceSettingsSection.TextureOptimization + : PerformanceSettingsSection.ModelOptimization; + _lightlessMediator.Publish(new OpenPerformanceSettingsMessage(target)); + _optimizationPopupOpen = false; + ImGui.CloseCurrentPopup(); + } + + private static (string Title, FontAwesomeIcon Icon, Vector4 Color, OptimizationPanelSection Section) GetPopupHeaderData(OptimizationPanelSection section) + { + return section == OptimizationPanelSection.Texture + ? ("Texture Optimization", FontAwesomeIcon.Images, UIColors.Get("LightlessYellow"), OptimizationPanelSection.Texture) + : ("Model Optimization", FontAwesomeIcon.ProjectDiagram, UIColors.Get("LightlessOrange"), OptimizationPanelSection.Model); + } + + [StructLayout(LayoutKind.Auto)] + private readonly record struct PerformanceTotals( + long DisplayVramBytes, + long OriginalVramBytes, + long EffectiveVramBytes, + long DisplayTriangleCount, + long OriginalTriangleCount, + long EffectiveTriangleCount, + bool HasVramData, + bool HasOriginalVram, + bool HasEffectiveVram, + bool HasTriangleData, + bool HasOriginalTriangles, + bool HasEffectiveTriangles); +} diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index 6cc4bd1..e9a9c53 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -325,16 +325,13 @@ public class DownloadUi : WindowMediatorSubscriberBase if (hasValidSize) { - if (dlProg > 0) - { - fillPercent = transferredBytes / (double)totalBytes; - showFill = true; - } - else if (dlDecomp > 0 || dlComplete > 0 || transferredBytes >= totalBytes) + fillPercent = totalBytes > 0 ? transferredBytes / (double)totalBytes : 0.0; + if (isAllComplete && totalBytes > 0) { fillPercent = 1.0; - showFill = true; } + + showFill = transferredBytes > 0 || isAllComplete; } if (showFill) diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 9c2f1ef..bc31556 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -25,6 +25,7 @@ using LightlessSync.Services.LightFinder; using LightlessSync.Services.Mediator; using LightlessSync.Services.PairProcessing; using LightlessSync.Services.ServerConfiguration; +using LightlessSync.UI.Components; using LightlessSync.UI.Models; using LightlessSync.UI.Services; using LightlessSync.UI.Style; @@ -66,6 +67,7 @@ public class SettingsUi : WindowMediatorSubscriberBase private readonly PairUiService _pairUiService; private readonly PerformanceCollectorService _performanceCollector; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; + private readonly OptimizationSettingsPanel _optimizationSettingsPanel; private readonly PairProcessingLimiter _pairProcessingLimiter; private readonly EventAggregator _eventAggregator; private readonly ServerConfigurationManager _serverConfigurationManager; @@ -133,6 +135,12 @@ public class SettingsUi : WindowMediatorSubscriberBase private readonly Dictionary _generalTreeHighlights = new(StringComparer.Ordinal); private const float GeneralTreeHighlightDuration = 1.5f; private readonly SeluneBrush _generalSeluneBrush = new(); + private string? _performanceScrollTarget = null; + private string? _performanceOpenTreeTarget = null; + private const string PerformanceWarningsLabel = "Warnings"; + private const string PerformanceAutoPauseLabel = "Auto Pause"; + private const string PerformanceTextureOptimizationLabel = "Texture Optimization"; + private const string PerformanceModelOptimizationLabel = "Model Optimization"; private static readonly (string Label, SeIconChar Icon)[] LightfinderIconPresets = new[] { @@ -208,6 +216,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _httpClient = httpClient; _fileCompactor = fileCompactor; _uiShared = uiShared; + _optimizationSettingsPanel = new OptimizationSettingsPanel(_uiShared, _playerPerformanceConfigService, _pairUiService); _nameplateService = nameplateService; _actorObjectService = actorObjectService; _validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v); @@ -229,6 +238,11 @@ public class SettingsUi : WindowMediatorSubscriberBase _selectGeneralTabOnNextDraw = true; FocusGeneralTree("Lightfinder"); }); + Mediator.Subscribe(this, msg => + { + IsOpen = true; + FocusPerformanceSection(msg.Section); + }); Mediator.Subscribe(this, (_) => IsOpen = false); Mediator.Subscribe(this, (_) => UiSharedService_GposeStart()); Mediator.Subscribe(this, (_) => UiSharedService_GposeEnd()); @@ -516,162 +530,6 @@ public class SettingsUi : WindowMediatorSubscriberBase } } - private void DrawTextureDownscaleCounters() - { - HashSet trackedPairs = new(); - - var snapshot = _pairUiService.GetSnapshot(); - - foreach (var pair in snapshot.DirectPairs) - { - trackedPairs.Add(pair); - } - - foreach (var group in snapshot.GroupPairs.Values) - { - foreach (var pair in group) - { - trackedPairs.Add(pair); - } - } - - long totalOriginalBytes = 0; - long totalEffectiveBytes = 0; - var hasData = false; - - foreach (var pair in trackedPairs) - { - if (!pair.IsVisible) - continue; - - var original = pair.LastAppliedApproximateVRAMBytes; - var effective = pair.LastAppliedApproximateEffectiveVRAMBytes; - - if (original >= 0) - { - hasData = true; - totalOriginalBytes += original; - } - - if (effective >= 0) - { - hasData = true; - totalEffectiveBytes += effective; - } - } - - if (!hasData) - { - ImGui.TextDisabled("VRAM usage has not been calculated yet."); - return; - } - - var savedBytes = Math.Max(0L, totalOriginalBytes - totalEffectiveBytes); - var originalText = UiSharedService.ByteToString(totalOriginalBytes, addSuffix: true); - var effectiveText = UiSharedService.ByteToString(totalEffectiveBytes, addSuffix: true); - var savedText = UiSharedService.ByteToString(savedBytes, addSuffix: true); - - ImGui.TextUnformatted($"Total VRAM usage (original): {originalText}"); - ImGui.TextUnformatted($"Total VRAM usage (effective): {effectiveText}"); - - if (savedBytes > 0) - { - UiSharedService.ColorText($"VRAM saved by downscaling: {savedText}", UIColors.Get("LightlessGreen")); - } - else - { - ImGui.TextUnformatted($"VRAM saved by downscaling: {savedText}"); - } - } - - private void DrawTriangleDecimationCounters() - { - HashSet trackedPairs = new(); - - var snapshot = _pairUiService.GetSnapshot(); - - foreach (var pair in snapshot.DirectPairs) - { - trackedPairs.Add(pair); - } - - foreach (var group in snapshot.GroupPairs.Values) - { - foreach (var pair in group) - { - trackedPairs.Add(pair); - } - } - - long totalOriginalTris = 0; - long totalEffectiveTris = 0; - var hasData = false; - - foreach (var pair in trackedPairs) - { - if (!pair.IsVisible) - continue; - - var original = pair.LastAppliedDataTris; - var effective = pair.LastAppliedApproximateEffectiveTris; - - if (original >= 0) - { - hasData = true; - totalOriginalTris += original; - } - - if (effective >= 0) - { - hasData = true; - totalEffectiveTris += effective; - } - } - - if (!hasData) - { - ImGui.TextDisabled("Triangle usage has not been calculated yet."); - return; - } - - var savedTris = Math.Max(0L, totalOriginalTris - totalEffectiveTris); - var originalText = FormatTriangleCount(totalOriginalTris); - var effectiveText = FormatTriangleCount(totalEffectiveTris); - var savedText = FormatTriangleCount(savedTris); - - ImGui.TextUnformatted($"Total triangle usage (original): {originalText}"); - ImGui.TextUnformatted($"Total triangle usage (effective): {effectiveText}"); - - if (savedTris > 0) - { - UiSharedService.ColorText($"Triangles saved by decimation: {savedText}", UIColors.Get("LightlessGreen")); - } - else - { - ImGui.TextUnformatted($"Triangles saved by decimation: {savedText}"); - } - - static string FormatTriangleCount(long triangleCount) - { - if (triangleCount < 0) - { - return "n/a"; - } - - if (triangleCount >= 1_000_000) - { - return FormattableString.Invariant($"{triangleCount / 1_000_000d:0.#}m tris"); - } - - if (triangleCount >= 1_000) - { - return FormattableString.Invariant($"{triangleCount / 1_000d:0.#}k tris"); - } - - return $"{triangleCount} tris"; - } - } - private void DrawThemeVectorRow(MainStyle.StyleVector2Option option) { ImGui.TableNextRow(); @@ -1593,6 +1451,24 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.SameLine(); ImGui.TextColored(statusColor, $"[{(pair.IsVisible ? "Visible" : pair.IsOnline ? "Online" : "Offline")}]"); + if (_uiShared.IconTextButton(FontAwesomeIcon.Copy, "Copy Pair Diagnostics##pairDebugCopy")) + { + ImGui.SetClipboardText(BuildPairDiagnosticsClipboard(pair, snapshot)); + } + + UiSharedService.AttachToolTip("Copies the current pair diagnostics to the clipboard."); + + ImGui.SameLine(); + if (_uiShared.IconTextButton(FontAwesomeIcon.Copy, "Copy Last Data JSON##pairDebugCopyLastData")) + { + var lastDataForClipboard = pair.LastReceivedCharacterData; + ImGui.SetClipboardText(lastDataForClipboard is null + ? "ERROR: No character data has been received for this pair." + : JsonSerializer.Serialize(lastDataForClipboard, DebugJsonOptions)); + } + + UiSharedService.AttachToolTip("Copies the last received character data JSON to the clipboard."); + if (ImGui.BeginTable("##pairDebugProperties", 2, ImGuiTableFlags.SizingStretchProp)) { DrawPairPropertyRow("UID", pair.UserData.UID); @@ -1722,6 +1598,141 @@ public class SettingsUi : WindowMediatorSubscriberBase DrawPairEventLog(pair); } + private string BuildPairDiagnosticsClipboard(Pair pair, PairUiSnapshot snapshot) + { + var debugInfo = pair.GetDebugInfo(); + StringBuilder sb = new(); + sb.AppendLine("Lightless Pair Diagnostics"); + sb.AppendLine($"Generated: {DateTime.Now.ToString("G", CultureInfo.CurrentCulture)}"); + sb.AppendLine(); + + sb.AppendLine("Pair"); + sb.AppendLine($"Alias/UID: {pair.UserData.AliasOrUID}"); + sb.AppendLine($"UID: {pair.UserData.UID}"); + sb.AppendLine($"Alias: {(string.IsNullOrEmpty(pair.UserData.Alias) ? "(none)" : pair.UserData.Alias)}"); + sb.AppendLine($"Player Name: {pair.PlayerName ?? "(not cached)"}"); + sb.AppendLine($"Handler Ident: {(string.IsNullOrEmpty(pair.Ident) ? "(not bound)" : pair.Ident)}"); + sb.AppendLine($"Character Id: {FormatCharacterId(pair.PlayerCharacterId)}"); + sb.AppendLine($"Direct Pair: {FormatBool(pair.IsDirectlyPaired)}"); + sb.AppendLine($"Individual Status: {pair.IndividualPairStatus}"); + sb.AppendLine($"Any Connection: {FormatBool(pair.HasAnyConnection())}"); + sb.AppendLine($"Paused: {FormatBool(pair.IsPaused)}"); + sb.AppendLine($"Visible: {FormatBool(pair.IsVisible)}"); + sb.AppendLine($"Online: {FormatBool(pair.IsOnline)}"); + sb.AppendLine($"Has Handler: {FormatBool(debugInfo.HasHandler)}"); + sb.AppendLine($"Handler Initialized: {FormatBool(debugInfo.HandlerInitialized)}"); + sb.AppendLine($"Handler Visible: {FormatBool(debugInfo.HandlerVisible)}"); + sb.AppendLine($"Last Time person rendered in: {FormatTimestamp(debugInfo.InvisibleSinceUtc)}"); + sb.AppendLine($"Handler Timer Temp Collection removal: {FormatCountdown(debugInfo.VisibilityEvictionRemainingSeconds)}"); + sb.AppendLine($"Handler Scheduled For Deletion: {FormatBool(debugInfo.HandlerScheduledForDeletion)}"); + sb.AppendLine($"Note: {pair.GetNote() ?? "(none)"}"); + + sb.AppendLine(); + sb.AppendLine("Applied Data"); + sb.AppendLine($"Last Data Size: {FormatBytes(pair.LastAppliedDataBytes)}"); + sb.AppendLine($"Approx. VRAM: {FormatBytes(pair.LastAppliedApproximateVRAMBytes)}"); + sb.AppendLine($"Effective VRAM: {FormatBytes(pair.LastAppliedApproximateEffectiveVRAMBytes)}"); + sb.AppendLine($"Last Triangles: {(pair.LastAppliedDataTris < 0 ? "n/a" : pair.LastAppliedDataTris.ToString(CultureInfo.InvariantCulture))}"); + sb.AppendLine($"Effective Triangles: {(pair.LastAppliedApproximateEffectiveTris < 0 ? "n/a" : pair.LastAppliedApproximateEffectiveTris.ToString(CultureInfo.InvariantCulture))}"); + + sb.AppendLine(); + sb.AppendLine("Last Received Character Data"); + var lastData = pair.LastReceivedCharacterData; + if (lastData is null) + { + sb.AppendLine("None"); + } + else + { + var fileReplacementCount = lastData.FileReplacements.Values.Sum(list => list?.Count ?? 0); + var totalGamePaths = lastData.FileReplacements.Values.Sum(list => list?.Sum(replacement => replacement.GamePaths.Length) ?? 0); + sb.AppendLine($"File replacements: {fileReplacementCount} entries across {totalGamePaths} game paths."); + sb.AppendLine($"Customize+: {lastData.CustomizePlusData.Count}, Glamourer entries: {lastData.GlamourerData.Count}"); + sb.AppendLine($"Manipulation length: {lastData.ManipulationData.Length}, Heels set: {FormatBool(!string.IsNullOrEmpty(lastData.HeelsData))}"); + } + + sb.AppendLine(); + sb.AppendLine("Application Timeline"); + sb.AppendLine($"Last Data Received: {FormatTimestamp(debugInfo.LastDataReceivedAt)}"); + sb.AppendLine($"Last Apply Attempt: {FormatTimestamp(debugInfo.LastApplyAttemptAt)}"); + sb.AppendLine($"Last Successful Apply: {FormatTimestamp(debugInfo.LastSuccessfulApplyAt)}"); + + if (!string.IsNullOrEmpty(debugInfo.LastFailureReason)) + { + sb.AppendLine(); + sb.AppendLine($"Last failure: {debugInfo.LastFailureReason}"); + if (debugInfo.BlockingConditions.Count > 0) + { + sb.AppendLine("Blocking conditions:"); + foreach (var condition in debugInfo.BlockingConditions) + { + sb.AppendLine($"- {condition}"); + } + } + } + + sb.AppendLine(); + sb.AppendLine("Application & Download State"); + sb.AppendLine($"Applying Data: {FormatBool(debugInfo.IsApplying)}"); + sb.AppendLine($"Downloading: {FormatBool(debugInfo.IsDownloading)}"); + sb.AppendLine($"Pending Downloads: {debugInfo.PendingDownloadCount.ToString(CultureInfo.InvariantCulture)}"); + sb.AppendLine($"Forbidden Downloads: {debugInfo.ForbiddenDownloadCount.ToString(CultureInfo.InvariantCulture)}"); + sb.AppendLine($"Pending Mod Reapply: {FormatBool(debugInfo.PendingModReapply)}"); + sb.AppendLine($"Mod Apply Deferred: {FormatBool(debugInfo.ModApplyDeferred)}"); + sb.AppendLine($"Missing Critical Mods: {debugInfo.MissingCriticalMods.ToString(CultureInfo.InvariantCulture)}"); + sb.AppendLine($"Missing Non-Critical Mods: {debugInfo.MissingNonCriticalMods.ToString(CultureInfo.InvariantCulture)}"); + sb.AppendLine($"Missing Forbidden Mods: {debugInfo.MissingForbiddenMods.ToString(CultureInfo.InvariantCulture)}"); + + sb.AppendLine(); + sb.AppendLine("Syncshell Memberships"); + if (snapshot.PairsWithGroups.TryGetValue(pair, out var groups) && groups.Count > 0) + { + foreach (var group in groups.OrderBy(g => g.Group.AliasOrGID, StringComparer.OrdinalIgnoreCase)) + { + var flags = group.GroupPairUserInfos.TryGetValue(pair.UserData.UID, out var info) ? info : GroupPairUserInfo.None; + var flagLabel = flags switch + { + GroupPairUserInfo.None => string.Empty, + _ => $" ({string.Join(", ", GetGroupInfoFlags(flags))})" + }; + sb.AppendLine($"{group.Group.AliasOrGID} [{group.Group.GID}]{flagLabel}"); + } + } + else + { + sb.AppendLine("Not a member of any syncshells."); + } + + sb.AppendLine(); + sb.AppendLine("Pair DTO Snapshot"); + if (pair.UserPair is null) + { + sb.AppendLine("(unavailable)"); + } + else + { + sb.AppendLine(JsonSerializer.Serialize(pair.UserPair, DebugJsonOptions)); + } + + var relevantEvents = GetRelevantPairEvents(pair, 40); + sb.AppendLine(); + sb.AppendLine("Recent Events"); + if (relevantEvents.Count == 0) + { + sb.AppendLine("No recent events were logged for this pair."); + } + else + { + foreach (var ev in relevantEvents) + { + var timestamp = ev.EventTime.ToString("T", CultureInfo.CurrentCulture); + sb.AppendLine($"{timestamp} [{ev.EventSource}] {ev.EventSeverity}: {ev.Message}"); + } + } + + return sb.ToString(); + } + private static IEnumerable GetGroupInfoFlags(GroupPairUserInfo info) { if (info.HasFlag(GroupPairUserInfo.IsModerator)) @@ -1735,23 +1746,28 @@ public class SettingsUi : WindowMediatorSubscriberBase } } - private void DrawPairEventLog(Pair pair) + private List GetRelevantPairEvents(Pair pair, int maxEvents) { - ImGui.TextUnformatted("Recent Events"); var events = _eventAggregator.EventList.Value; var alias = pair.UserData.Alias; var aliasOrUid = pair.UserData.AliasOrUID; var rawUid = pair.UserData.UID; var playerName = pair.PlayerName; - var relevantEvents = events.Where(e => + return events.Where(e => EventMatchesIdentifier(e, rawUid) || EventMatchesIdentifier(e, aliasOrUid) || EventMatchesIdentifier(e, alias) || (!string.IsNullOrEmpty(playerName) && string.Equals(e.Character, playerName, StringComparison.OrdinalIgnoreCase))) .OrderByDescending(e => e.EventTime) - .Take(40) + .Take(maxEvents) .ToList(); + } + + private void DrawPairEventLog(Pair pair) + { + ImGui.TextUnformatted("Recent Events"); + var relevantEvents = GetRelevantPairEvents(pair, 40); if (relevantEvents.Count == 0) { @@ -2290,11 +2306,29 @@ public class SettingsUi : WindowMediatorSubscriberBase var syncshellOfflineSeparate = _configService.Current.ShowSyncshellOfflineUsersSeparately; var greenVisiblePair = _configService.Current.ShowVisiblePairsGreenEye; var enableParticleEffects = _configService.Current.EnableParticleEffects; + var showUiWhenUiHidden = _configService.Current.ShowUiWhenUiHidden; + var showUiInGpose = _configService.Current.ShowUiInGpose; using (var behaviorTree = BeginGeneralTree("Behavior", UIColors.Get("LightlessPurple"))) { if (behaviorTree.Visible) { + if (ImGui.Checkbox("Show Lightless windows when game UI is hidden", ref showUiWhenUiHidden)) + { + _configService.Current.ShowUiWhenUiHidden = showUiWhenUiHidden; + _configService.Save(); + } + + _uiShared.DrawHelpText("When disabled, Lightless windows (except chat) are hidden when the game UI is hidden."); + + if (ImGui.Checkbox("Show Lightless windows in group pose", ref showUiInGpose)) + { + _configService.Current.ShowUiInGpose = showUiInGpose; + _configService.Save(); + } + + _uiShared.DrawHelpText("When disabled, Lightless windows (except chat) are hidden while in group pose."); + if (ImGui.Checkbox("Enable Particle Effects", ref enableParticleEffects)) { _configService.Current.EnableParticleEffects = enableParticleEffects; @@ -3401,6 +3435,43 @@ public class SettingsUi : WindowMediatorSubscriberBase _generalTreeHighlights[label] = ImGui.GetTime(); } + private void FocusPerformanceSection(PerformanceSettingsSection section) + { + _selectGeneralTabOnNextDraw = false; + _selectedMainTab = MainSettingsTab.Performance; + var label = section switch + { + PerformanceSettingsSection.TextureOptimization => PerformanceTextureOptimizationLabel, + PerformanceSettingsSection.ModelOptimization => PerformanceModelOptimizationLabel, + _ => PerformanceTextureOptimizationLabel, + }; + _performanceOpenTreeTarget = label; + _performanceScrollTarget = label; + } + + private bool BeginPerformanceTree(string label, Vector4 color) + { + var shouldForceOpen = string.Equals(_performanceOpenTreeTarget, label, StringComparison.Ordinal); + if (shouldForceOpen) + { + ImGui.SetNextItemOpen(true, ImGuiCond.Always); + } + + var open = _uiShared.MediumTreeNode(label, color); + if (shouldForceOpen) + { + _performanceOpenTreeTarget = null; + } + + if (open && string.Equals(_performanceScrollTarget, label, StringComparison.Ordinal)) + { + ImGui.SetScrollHereY(0f); + _performanceScrollTarget = null; + } + + return open; + } + private float GetGeneralTreeHighlightAlpha(string label) { if (!_generalTreeHighlights.TryGetValue(label, out var startTime)) @@ -3490,7 +3561,7 @@ public class SettingsUi : WindowMediatorSubscriberBase bool showPerformanceIndicator = _playerPerformanceConfigService.Current.ShowPerformanceIndicator; - if (_uiShared.MediumTreeNode("Warnings", UIColors.Get("LightlessPurple"))) + if (BeginPerformanceTree(PerformanceWarningsLabel, UIColors.Get("LightlessPurple"))) { if (ImGui.Checkbox("Show performance indicator", ref showPerformanceIndicator)) { @@ -3586,7 +3657,7 @@ public class SettingsUi : WindowMediatorSubscriberBase bool autoPauseInCombat = _playerPerformanceConfigService.Current.PauseInCombat; bool autoPauseWhilePerforming = _playerPerformanceConfigService.Current.PauseWhilePerforming; - if (_uiShared.MediumTreeNode("Auto Pause", UIColors.Get("LightlessPurple"))) + if (BeginPerformanceTree(PerformanceAutoPauseLabel, UIColors.Get("LightlessPurple"))) { if (ImGui.Checkbox("Auto pause sync while combat", ref autoPauseInCombat)) { @@ -3683,261 +3754,12 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Separator(); - if (_uiShared.MediumTreeNode("Texture Optimization", UIColors.Get("LightlessYellow"))) - { - _uiShared.MediumText("Warning", UIColors.Get("DimRed")); - _uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"), - new SeStringUtils.RichTextEntry("Texture compression and downscaling is potentially a "), - new SeStringUtils.RichTextEntry("destructive", UIColors.Get("DimRed"), true), - new SeStringUtils.RichTextEntry(" process and may cause broken or incorrect character appearances.")); - - _uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"), - new SeStringUtils.RichTextEntry("This feature is encouraged to help "), - new SeStringUtils.RichTextEntry("lower-end systems with limited VRAM", UIColors.Get("LightlessYellow"), true), - new SeStringUtils.RichTextEntry(" and for use in "), - new SeStringUtils.RichTextEntry("performance-critical scenarios", UIColors.Get("LightlessYellow"), true), - new SeStringUtils.RichTextEntry(".")); - - _uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"), - new SeStringUtils.RichTextEntry("Runtime downscaling "), - new SeStringUtils.RichTextEntry("MAY", UIColors.Get("DimRed"), true), - new SeStringUtils.RichTextEntry(" cause higher load on the system when processing downloads.")); - - _uiShared.DrawNoteLine("!!! ", UIColors.Get("DimRed"), - new SeStringUtils.RichTextEntry("When enabled, we cannot provide support for appearance issues caused by this setting!", UIColors.Get("DimRed"), true)); - - var textureConfig = _playerPerformanceConfigService.Current; - var trimNonIndex = textureConfig.EnableNonIndexTextureMipTrim; - if (ImGui.Checkbox("Trim mip levels for textures", ref trimNonIndex)) - { - textureConfig.EnableNonIndexTextureMipTrim = trimNonIndex; - _playerPerformanceConfigService.Save(); - } - _uiShared.DrawHelpText("When enabled, Lightless will remove high-resolution mip levels from textures (not index) that exceed the size limit and are not compressed with any kind compression."); - - var downscaleIndex = textureConfig.EnableIndexTextureDownscale; - if (ImGui.Checkbox("Downscale index textures above limit", ref downscaleIndex)) - { - textureConfig.EnableIndexTextureDownscale = downscaleIndex; - _playerPerformanceConfigService.Save(); - } - _uiShared.DrawHelpText("Controls whether Lightless reduces index textures that exceed the size limit."); - - var dimensionOptions = new[] { 512, 1024, 2048, 4096 }; - var optionLabels = dimensionOptions.Select(selector: static value => value.ToString()).ToArray(); - var currentDimension = textureConfig.TextureDownscaleMaxDimension; - var selectedIndex = Array.IndexOf(dimensionOptions, currentDimension); - if (selectedIndex < 0) - { - selectedIndex = Array.IndexOf(dimensionOptions, 2048); - } - - ImGui.SetNextItemWidth(140 * ImGuiHelpers.GlobalScale); - if (ImGui.Combo("Maximum texture dimension", ref selectedIndex, optionLabels, optionLabels.Length)) - { - textureConfig.TextureDownscaleMaxDimension = dimensionOptions[selectedIndex]; - _playerPerformanceConfigService.Save(); - } - _uiShared.DrawHelpText($"Textures above this size will be reduced until their largest dimension is at or below the limit. Block-compressed textures are skipped when \"Only downscale uncompressed\" is enabled.{UiSharedService.TooltipSeparator}Default: 2048"); - - var keepOriginalTextures = textureConfig.KeepOriginalTextureFiles; - if (ImGui.Checkbox("Keep original texture files", ref keepOriginalTextures)) - { - textureConfig.KeepOriginalTextureFiles = keepOriginalTextures; - _playerPerformanceConfigService.Save(); - } - _uiShared.DrawHelpText("When disabled, Lightless removes the original texture after a downscaled copy is created."); - ImGui.SameLine(); - _uiShared.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), new SeStringUtils.RichTextEntry("If disabled, saved + effective VRAM usage information will not work.", UIColors.Get("LightlessYellow"))); - - var skipPreferredDownscale = textureConfig.SkipTextureDownscaleForPreferredPairs; - if (ImGui.Checkbox("Skip downscale for preferred/direct pairs", ref skipPreferredDownscale)) - { - textureConfig.SkipTextureDownscaleForPreferredPairs = skipPreferredDownscale; - _playerPerformanceConfigService.Save(); - } - _uiShared.DrawHelpText("When enabled, textures for direct pairs with preferred permissions are left untouched."); - - if (!textureConfig.EnableNonIndexTextureMipTrim && !textureConfig.EnableIndexTextureDownscale) - { - UiSharedService.ColorTextWrapped("Both trimming and downscale are disabled. Lightless will keep original textures regardless of size.", UIColors.Get("DimRed")); - } - - ImGui.Dummy(new Vector2(5)); - - UiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 3f); - var onlyUncompressed = textureConfig.OnlyDownscaleUncompressedTextures; - if (ImGui.Checkbox("Only downscale uncompressed textures", ref onlyUncompressed)) - { - textureConfig.OnlyDownscaleUncompressedTextures = onlyUncompressed; - _playerPerformanceConfigService.Save(); - } - _uiShared.DrawHelpText("If disabled, compressed textures will be targeted for downscaling too."); - UiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 3f); - - ImGui.Dummy(new Vector2(5)); - - DrawTextureDownscaleCounters(); - - ImGui.Dummy(new Vector2(5)); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.5f); - ImGui.TreePop(); - } - - ImGui.Separator(); - - if (_uiShared.MediumTreeNode("Model Optimization", UIColors.Get("DimRed"))) - { - _uiShared.MediumText("Warning", UIColors.Get("DimRed")); - _uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"), - new SeStringUtils.RichTextEntry("Model decimation is a "), - new SeStringUtils.RichTextEntry("destructive", UIColors.Get("DimRed"), true), - new SeStringUtils.RichTextEntry(" process and may cause broken or incorrect character appearances.")); - - - _uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"), - new SeStringUtils.RichTextEntry("This feature is encouraged to help "), - new SeStringUtils.RichTextEntry("lower-end systems with limited VRAM", UIColors.Get("LightlessYellow"), true), - new SeStringUtils.RichTextEntry(" and for use in "), - new SeStringUtils.RichTextEntry("performance-critical scenarios", UIColors.Get("LightlessYellow"), true), - new SeStringUtils.RichTextEntry(".")); - - _uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"), - new SeStringUtils.RichTextEntry("Runtime decimation "), - new SeStringUtils.RichTextEntry("MAY", UIColors.Get("DimRed"), true), - new SeStringUtils.RichTextEntry(" cause higher load on the system when processing downloads.")); - - _uiShared.DrawNoteLine("!!! ", UIColors.Get("DimRed"), - new SeStringUtils.RichTextEntry("When enabled, we cannot provide support for appearance issues caused by this setting!", UIColors.Get("DimRed"), true)); - - ImGui.Dummy(new Vector2(15)); - - _uiShared.DrawNoteLine("! ", UIColors.Get("LightlessGreen"), - new SeStringUtils.RichTextEntry("If a mesh exceeds the "), - new SeStringUtils.RichTextEntry("triangle threshold", UIColors.Get("LightlessGreen"), true), - new SeStringUtils.RichTextEntry(", it will be decimated automatically to the set "), - new SeStringUtils.RichTextEntry("target triangle ratio", UIColors.Get("LightlessGreen"), true), - new SeStringUtils.RichTextEntry(". This will reduce quality of the mesh or may break it's intended structure.")); - - - var performanceConfig = _playerPerformanceConfigService.Current; - var enableDecimation = performanceConfig.EnableModelDecimation; - if (ImGui.Checkbox("Enable model decimation", ref enableDecimation)) - { - performanceConfig.EnableModelDecimation = enableDecimation; - _playerPerformanceConfigService.Save(); - } - _uiShared.DrawHelpText("When enabled, Lightless generates a decimated copy of given model after download."); - - var keepOriginalModels = performanceConfig.KeepOriginalModelFiles; - if (ImGui.Checkbox("Keep original model files", ref keepOriginalModels)) - { - performanceConfig.KeepOriginalModelFiles = keepOriginalModels; - _playerPerformanceConfigService.Save(); - } - _uiShared.DrawHelpText("When disabled, Lightless removes the original model after a decimated copy is created."); - ImGui.SameLine(); - _uiShared.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), new SeStringUtils.RichTextEntry("If disabled, saved + effective triangle usage information will not work.", UIColors.Get("LightlessYellow"))); - - var skipPreferredDecimation = performanceConfig.SkipModelDecimationForPreferredPairs; - if (ImGui.Checkbox("Skip decimation for preferred/direct pairs", ref skipPreferredDecimation)) - { - performanceConfig.SkipModelDecimationForPreferredPairs = skipPreferredDecimation; - _playerPerformanceConfigService.Save(); - } - _uiShared.DrawHelpText("When enabled, models for direct pairs with preferred permissions are left untouched."); - - var triangleThreshold = performanceConfig.ModelDecimationTriangleThreshold; - ImGui.SetNextItemWidth(300 * ImGuiHelpers.GlobalScale); - if (ImGui.SliderInt("Decimate models above", ref triangleThreshold, 8_000, 100_000)) - { - performanceConfig.ModelDecimationTriangleThreshold = Math.Clamp(triangleThreshold, 8_000, 100_000); - _playerPerformanceConfigService.Save(); - } - ImGui.SameLine(); - ImGui.Text("triangles"); - _uiShared.DrawHelpText($"Models below this triangle count are left untouched.{UiSharedService.TooltipSeparator}Default: 50,000"); - - var targetPercent = (float)(performanceConfig.ModelDecimationTargetRatio * 100.0); - var clampedPercent = Math.Clamp(targetPercent, 60f, 99f); - if (Math.Abs(clampedPercent - targetPercent) > float.Epsilon) - { - performanceConfig.ModelDecimationTargetRatio = clampedPercent / 100.0; - _playerPerformanceConfigService.Save(); - targetPercent = clampedPercent; - } - ImGui.SetNextItemWidth(300 * ImGuiHelpers.GlobalScale); - if (ImGui.SliderFloat("Target triangle ratio", ref targetPercent, 60f, 99f, "%.0f%%")) - { - performanceConfig.ModelDecimationTargetRatio = Math.Clamp(targetPercent / 100f, 0.6f, 0.99f); - _playerPerformanceConfigService.Save(); - } - _uiShared.DrawHelpText($"Target ratio relative to original triangle count (80% keeps 80% of triangles).{UiSharedService.TooltipSeparator}Default: 80%"); - - ImGui.Dummy(new Vector2(15)); - ImGui.TextUnformatted("Decimation targets"); - _uiShared.DrawHelpText("Hair mods are always excluded from decimation."); - - _uiShared.DrawNoteLine("! ", UIColors.Get("LightlessGreen"), - new SeStringUtils.RichTextEntry("Automatic decimation will only target the selected "), - new SeStringUtils.RichTextEntry("decimation targets", UIColors.Get("LightlessGreen"), true), - new SeStringUtils.RichTextEntry(".")); - - _uiShared.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), - new SeStringUtils.RichTextEntry("It is advised to not decimate any body related meshes which includes: "), - new SeStringUtils.RichTextEntry("facial mods + sculpts, chest, legs, hands and feet", UIColors.Get("LightlessYellow"), true), - new SeStringUtils.RichTextEntry(".")); - - _uiShared.DrawNoteLine("!!! ", UIColors.Get("DimRed"), - new SeStringUtils.RichTextEntry("Remember, automatic decimation is not perfect and can cause meshes to be ruined, especially hair mods.", UIColors.Get("DimRed"), true)); - - var allowBody = performanceConfig.ModelDecimationAllowBody; - if (ImGui.Checkbox("Body", ref allowBody)) - { - performanceConfig.ModelDecimationAllowBody = allowBody; - _playerPerformanceConfigService.Save(); - } - - var allowFaceHead = performanceConfig.ModelDecimationAllowFaceHead; - if (ImGui.Checkbox("Face/head", ref allowFaceHead)) - { - performanceConfig.ModelDecimationAllowFaceHead = allowFaceHead; - _playerPerformanceConfigService.Save(); - } - - var allowTail = performanceConfig.ModelDecimationAllowTail; - if (ImGui.Checkbox("Tails/Ears", ref allowTail)) - { - performanceConfig.ModelDecimationAllowTail = allowTail; - _playerPerformanceConfigService.Save(); - } - - var allowClothing = performanceConfig.ModelDecimationAllowClothing; - if (ImGui.Checkbox("Clothing (body/legs/shoes/gloves/hats)", ref allowClothing)) - { - performanceConfig.ModelDecimationAllowClothing = allowClothing; - _playerPerformanceConfigService.Save(); - } - - var allowAccessories = performanceConfig.ModelDecimationAllowAccessories; - if (ImGui.Checkbox("Accessories (earring/rings/bracelet/necklace)", ref allowAccessories)) - { - performanceConfig.ModelDecimationAllowAccessories = allowAccessories; - _playerPerformanceConfigService.Save(); - } - - ImGui.Dummy(new Vector2(5)); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessGrey"), 3f); - - ImGui.Dummy(new Vector2(5)); - DrawTriangleDecimationCounters(); - ImGui.Dummy(new Vector2(5)); - - UiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 1.5f); - ImGui.TreePop(); - } + _optimizationSettingsPanel.DrawSettingsTrees( + PerformanceTextureOptimizationLabel, + UIColors.Get("LightlessYellow"), + PerformanceModelOptimizationLabel, + UIColors.Get("LightlessOrange"), + BeginPerformanceTree); ImGui.Separator(); ImGui.Dummy(new Vector2(10)); diff --git a/LightlessSync/UI/Style/MainStyle.cs b/LightlessSync/UI/Style/MainStyle.cs index 53dd682..132dc2c 100644 --- a/LightlessSync/UI/Style/MainStyle.cs +++ b/LightlessSync/UI/Style/MainStyle.cs @@ -40,10 +40,10 @@ internal static class MainStyle new("color.frameBg", "Frame Background", () => Rgba(40, 40, 40, 255), ImGuiCol.FrameBg), new("color.frameBgHovered", "Frame Background (Hover)", () => Rgba(50, 50, 50, 100), ImGuiCol.FrameBgHovered), new("color.frameBgActive", "Frame Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.FrameBgActive), - new("color.titleBg", "Title Background", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBg), - new("color.titleBgActive", "Title Background (Active)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgActive), - new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgCollapsed), - + new("color.titleBg", "Title Background", () => Rgba(24, 24, 24, 232), ImGuiCol.TitleBg), + new("color.titleBgActive", "Title Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.TitleBgActive), + new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(27, 27, 27, 255), ImGuiCol.TitleBgCollapsed), + new("color.menuBarBg", "Menu Bar Background", () => Rgba(36, 36, 36, 255), ImGuiCol.MenuBarBg), new("color.scrollbarBg", "Scrollbar Background", () => Rgba(0, 0, 0, 0), ImGuiCol.ScrollbarBg), new("color.scrollbarGrab", "Scrollbar Grab", () => Rgba(62, 62, 62, 255), ImGuiCol.ScrollbarGrab), diff --git a/LightlessSync/UI/Style/Selune.cs b/LightlessSync/UI/Style/Selune.cs index f89a1f0..00843a8 100644 --- a/LightlessSync/UI/Style/Selune.cs +++ b/LightlessSync/UI/Style/Selune.cs @@ -29,6 +29,7 @@ public sealed class SeluneGradientSettings public Vector4 GradientColor { get; init; } = UIColors.Get("LightlessPurple"); public Vector4? HighlightColor { get; init; } public float GradientPeakOpacity { get; init; } = 0.07f; + public float GradientPeakPosition { get; init; } = 0.035f; public float HighlightPeakAlpha { get; init; } = 0.13f; public float HighlightEdgeAlpha { get; init; } = 0f; public float HighlightMidpoint { get; init; } = 0.45f; @@ -378,6 +379,7 @@ internal static class SeluneRenderer topColorVec, midColorVec, bottomColorVec, + settings, settings.BackgroundMode); } @@ -403,19 +405,21 @@ internal static class SeluneRenderer Vector4 topColorVec, Vector4 midColorVec, Vector4 bottomColorVec, + SeluneGradientSettings settings, SeluneGradientMode mode) { + var peakPosition = Math.Clamp(settings.GradientPeakPosition, 0.01f, 0.99f); switch (mode) { case SeluneGradientMode.Vertical: - DrawVerticalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec); + DrawVerticalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec, peakPosition); break; case SeluneGradientMode.Horizontal: - DrawHorizontalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec); + DrawHorizontalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec, peakPosition); break; case SeluneGradientMode.Both: - DrawVerticalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec); - DrawHorizontalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec); + DrawVerticalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec, peakPosition); + DrawHorizontalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec, peakPosition); break; } } @@ -428,13 +432,14 @@ internal static class SeluneRenderer float clampedBottomY, Vector4 topColorVec, Vector4 midColorVec, - Vector4 bottomColorVec) + Vector4 bottomColorVec, + float peakPosition) { var topColor = ImGui.ColorConvertFloat4ToU32(topColorVec); var midColor = ImGui.ColorConvertFloat4ToU32(midColorVec); var bottomColor = ImGui.ColorConvertFloat4ToU32(bottomColorVec); - var midY = clampedTopY + (clampedBottomY - clampedTopY) * 0.035f; + var midY = clampedTopY + (clampedBottomY - clampedTopY) * peakPosition; drawList.AddRectFilledMultiColor( new Vector2(gradientLeft, clampedTopY), new Vector2(gradientRight, midY), @@ -460,13 +465,14 @@ internal static class SeluneRenderer float clampedBottomY, Vector4 leftColorVec, Vector4 midColorVec, - Vector4 rightColorVec) + Vector4 rightColorVec, + float peakPosition) { var leftColor = ImGui.ColorConvertFloat4ToU32(leftColorVec); var midColor = ImGui.ColorConvertFloat4ToU32(midColorVec); var rightColor = ImGui.ColorConvertFloat4ToU32(rightColorVec); - var midX = gradientLeft + (gradientRight - gradientLeft) * 0.035f; + var midX = gradientLeft + (gradientRight - gradientLeft) * peakPosition; drawList.AddRectFilledMultiColor( new Vector2(gradientLeft, clampedTopY), new Vector2(midX, clampedBottomY), diff --git a/LightlessSync/UI/ZoneChatUi.cs b/LightlessSync/UI/ZoneChatUi.cs index 571b8ca..8f799de 100644 --- a/LightlessSync/UI/ZoneChatUi.cs +++ b/LightlessSync/UI/ZoneChatUi.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Numerics; +using System.Reflection; using LightlessSync.API.Data; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Data.Enum; @@ -8,9 +9,11 @@ using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; +using Dalamud.Interface.Windowing; using LightlessSync.API.Dto.Chat; using LightlessSync.API.Dto.Group; using LightlessSync.LightlessConfiguration; +using LightlessSync.LightlessConfiguration.Configurations; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.Services; using LightlessSync.Services.Chat; @@ -38,6 +41,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private const string ReportPopupId = "Report Message##zone_chat_report_popup"; private const string ChannelDragPayloadId = "zone_chat_channel_drag"; private const string EmotePickerPopupId = "zone_chat_emote_picker"; + private const string MentionPopupId = "zone_chat_mention_popup"; private const int EmotePickerColumns = 10; private const float DefaultWindowOpacity = .97f; private const float DefaultUnfocusedWindowOpacity = 0.6f; @@ -45,11 +49,37 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private const float MaxWindowOpacity = 1f; private const float MinChatFontScale = 0.75f; private const float MaxChatFontScale = 1.5f; + private const float MinEmoteScale = 0.5f; + private const float MaxEmoteScale = 2.0f; private const float UnfocusedFadeOutSpeed = 0.22f; private const float FocusFadeInSpeed = 2.0f; private const int ReportReasonMaxLength = 500; private const int ReportContextMaxLength = 1000; private const int MaxChannelNoteTabLength = 25; + private const int MaxBadgeDisplay = 99; + private const int MaxMentionSuggestions = 8; + private const int CollapsedMessageCountDisplayCap = 999; + + private static readonly FieldInfo? FadeOutOriginField = typeof(Window).GetField("fadeOutOrigin", BindingFlags.Instance | BindingFlags.NonPublic); + private static readonly FieldInfo? FadeOutSizeField = typeof(Window).GetField("fadeOutSize", BindingFlags.Instance | BindingFlags.NonPublic); + + private enum ChatSettingsTab + { + General, + Messages, + Notifications, + Visibility, + Window + } + + private static readonly UiSharedService.TabOption[] ChatSettingsTabOptions = + [ + new UiSharedService.TabOption("General", ChatSettingsTab.General), + new UiSharedService.TabOption("Messages", ChatSettingsTab.Messages), + new UiSharedService.TabOption("Notifications", ChatSettingsTab.Notifications), + new UiSharedService.TabOption("Visibility", ChatSettingsTab.Visibility), + new UiSharedService.TabOption("Window", ChatSettingsTab.Window), + ]; private readonly UiSharedService _uiSharedService; private readonly ZoneChatService _zoneChatService; @@ -66,6 +96,9 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private readonly Dictionary _draftMessages = new(StringComparer.Ordinal); private readonly Dictionary> _pendingDraftClears = new(StringComparer.Ordinal); private readonly ImGuiWindowFlags _unpinnedWindowFlags; + private string? _activeInputChannelKey; + private int _pendingDraftCursorPos = -1; + private string? _pendingDraftCursorChannelKey; private float _currentWindowOpacity = DefaultWindowOpacity; private float _baseWindowOpacity = DefaultWindowOpacity; private bool _isWindowPinned; @@ -94,9 +127,19 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private string? _dragHoverKey; private bool _openEmotePicker; private string _emoteFilter = string.Empty; + private int _mentionSelectionIndex = -1; + private string? _mentionSelectionKey; private bool _HideStateActive; private bool _HideStateWasOpen; private bool _pushedStyle; + private ChatSettingsTab _selectedChatSettingsTab = ChatSettingsTab.General; + private bool _isWindowCollapsed; + private bool _wasWindowCollapsed; + private int _collapsedMessageCount; + private bool _forceExpandOnOpen; + private Vector2 _lastWindowPos; + private Vector2 _lastWindowSize; + private bool _hasWindowMetrics; public ZoneChatUi( ILogger logger, @@ -158,7 +201,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase var config = _chatConfigService.Current; var baseOpacity = Math.Clamp(config.ChatWindowOpacity, MinWindowOpacity, MaxWindowOpacity); _baseWindowOpacity = baseOpacity; - ImGui.PushStyleVar(ImGuiStyleVar.WindowBorderSize, 0); + ImGui.PushStyleVar(ImGuiStyleVar.WindowBorderSize, 1f); _pushedStyle = true; if (config.FadeWhenUnfocused) @@ -245,11 +288,6 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase var config = _chatConfigService.Current; var isFocused = ImGui.IsWindowFocused(ImGuiFocusedFlags.RootAndChildWindows); var isHovered = ImGui.IsWindowHovered(ImGuiHoveredFlags.RootAndChildWindows); - if (config.FadeWhenUnfocused && isHovered && !isFocused) - { - ImGui.SetWindowFocus(); - } - _isWindowFocused = config.FadeWhenUnfocused ? (isFocused || isHovered) : isFocused; var contentAlpha = 1f; @@ -263,14 +301,20 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase var drawList = ImGui.GetWindowDrawList(); var windowPos = ImGui.GetWindowPos(); var windowSize = ImGui.GetWindowSize(); + _lastWindowPos = windowPos; + _lastWindowSize = windowSize; + _hasWindowMetrics = true; + UpdateCollapsedState(isCollapsed: false); using var selune = Selune.Begin(_seluneBrush, drawList, windowPos, windowSize); var childBgColor = ImGui.GetStyle().Colors[(int)ImGuiCol.ChildBg]; childBgColor.W *= _baseWindowOpacity; using var childBg = ImRaii.PushColor(ImGuiCol.ChildBg, childBgColor); DrawConnectionControls(); - var channels = _zoneChatService.GetChannelsSnapshot(); + IReadOnlyList channels = _zoneChatService.GetChannelsSnapshot(); + IReadOnlyList visibleChannels = GetVisibleChannels(channels); DrawReportPopup(); + CleanupDrafts(channels); if (channels.Count == 0) { @@ -278,12 +322,18 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.TextWrapped("No chat channels available."); ImGui.PopStyleColor(); } + else if (visibleChannels.Count == 0) + { + EnsureSelectedChannel(visibleChannels); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); + ImGui.TextWrapped("All chat channels are hidden. Open chat settings to show channels."); + ImGui.PopStyleColor(); + } else { - EnsureSelectedChannel(channels); - CleanupDrafts(channels); + EnsureSelectedChannel(visibleChannels); - DrawChannelButtons(channels); + DrawChannelButtons(visibleChannels); if (_selectedChannelKey is null) { @@ -291,10 +341,10 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase return; } - var activeChannel = channels.FirstOrDefault(channel => string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal)); + ChatChannelSnapshot activeChannel = visibleChannels.FirstOrDefault(channel => string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal)); if (activeChannel.Equals(default(ChatChannelSnapshot))) { - activeChannel = channels[0]; + activeChannel = visibleChannels[0]; _selectedChannelKey = activeChannel.Key; } @@ -331,6 +381,136 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase _titleBarStylePopCount = 3; } + private void DrawCollapsedMessageBadge(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize) + { + if (_collapsedMessageCount <= 0) + { + return; + } + + var style = ImGui.GetStyle(); + var titleBarHeight = ImGui.GetFontSize() + style.FramePadding.Y * 2f; + var scale = ImGuiHelpers.GlobalScale; + + var displayCount = _collapsedMessageCount > CollapsedMessageCountDisplayCap + ? $"{CollapsedMessageCountDisplayCap}+" + : _collapsedMessageCount.ToString(CultureInfo.InvariantCulture); + var padding = new Vector2(8f, 3f) * scale; + + var title = WindowName ?? string.Empty; + var titleSplitIndex = title.IndexOf("###", StringComparison.Ordinal); + if (titleSplitIndex >= 0) + { + title = title[..titleSplitIndex]; + } + var titleSize = ImGui.CalcTextSize(title); + var leftEdge = windowPos.X + style.FramePadding.X + titleSize.X + style.ItemInnerSpacing.X + 6f * scale; + + var buttonCount = GetTitleBarButtonCount(); + var buttonWidth = ImGui.GetFrameHeight(); + var buttonSpacing = style.ItemInnerSpacing.X; + var buttonArea = buttonCount > 0 + ? (buttonWidth * buttonCount) + (buttonSpacing * (buttonCount - 1)) + : 0f; + var rightEdge = windowPos.X + windowSize.X - style.FramePadding.X - buttonArea; + var availableWidth = rightEdge - leftEdge; + if (availableWidth <= 0f) + { + return; + } + + string label = $"New messages: {displayCount}"; + var textSize = ImGui.CalcTextSize(label); + var badgeSize = textSize + padding * 2f; + if (badgeSize.X > availableWidth) + { + label = $"New: {displayCount}"; + textSize = ImGui.CalcTextSize(label); + badgeSize = textSize + padding * 2f; + } + if (badgeSize.X > availableWidth) + { + label = displayCount; + textSize = ImGui.CalcTextSize(label); + badgeSize = textSize + padding * 2f; + } + if (badgeSize.X > availableWidth) + { + return; + } + + var posX = MathF.Max(leftEdge, rightEdge - badgeSize.X); + var posY = windowPos.Y + (titleBarHeight - badgeSize.Y) * 0.5f; + var badgeMin = new Vector2(posX, posY); + var badgeMax = badgeMin + badgeSize; + + var time = (float)ImGui.GetTime(); + var pulse = 0.6f + 0.2f * (1f + MathF.Sin(time * 2f)); + var baseColor = UIColors.Get("DimRed"); + var fillColor = new Vector4(baseColor.X, baseColor.Y, baseColor.Z, baseColor.W * pulse); + drawList.AddRectFilled(badgeMin, badgeMax, ImGui.ColorConvertFloat4ToU32(fillColor), 6f * scale); + drawList.AddText(badgeMin + padding, ImGui.ColorConvertFloat4ToU32(ImGuiColors.DalamudWhite), label); + } + + private int GetTitleBarButtonCount() + { + var count = 0; + if (!Flags.HasFlag(ImGuiWindowFlags.NoCollapse)) + { + count++; + } + + if (ShowCloseButton) + { + count++; + } + + if (AllowPinning || AllowClickthrough) + { + count++; + } + + count += TitleBarButtons?.Count ?? 0; + return count; + } + + private void UpdateCollapsedState(bool isCollapsed) + { + if (isCollapsed != _wasWindowCollapsed) + { + _collapsedMessageCount = 0; + _wasWindowCollapsed = isCollapsed; + } + + _isWindowCollapsed = isCollapsed; + } + + private bool TryUpdateWindowMetricsFromBase() + { + if (FadeOutOriginField is null || FadeOutSizeField is null) + { + return false; + } + + if (FadeOutOriginField.GetValue(this) is Vector2 pos && FadeOutSizeField.GetValue(this) is Vector2 size) + { + _lastWindowPos = pos; + _lastWindowSize = size; + _hasWindowMetrics = true; + return true; + } + + return false; + } + + private static bool IsLikelyCollapsed(Vector2 windowSize) + { + var style = ImGui.GetStyle(); + var titleHeight = ImGui.GetFontSize() + style.FramePadding.Y * 2f; + var threshold = titleHeight + style.WindowBorderSize * 2f + 2f * ImGuiHelpers.GlobalScale; + return windowSize.Y <= threshold; + } + private void DrawHeader(ChatChannelSnapshot channel) { var prefix = channel.Type == ChatChannelType.Zone ? "Zone" : "Syncshell"; @@ -418,6 +598,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase var showTimestamps = _chatConfigService.Current.ShowMessageTimestamps; _chatEmoteService.EnsureGlobalEmotesLoaded(); PairUiSnapshot? pairSnapshot = null; + MentionHighlightData? mentionHighlightData = null; var itemSpacing = ImGui.GetStyle().ItemSpacing.X; if (channel.Messages.Count == 0) @@ -428,6 +609,12 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } else { + if (channel.Type == ChatChannelType.Group) + { + pairSnapshot ??= _pairUiService.GetSnapshot(); + mentionHighlightData = BuildMentionHighlightData(channel, pairSnapshot); + } + var messageCount = channel.Messages.Count; var contentMaxX = ImGui.GetWindowContentRegionMax().X; var cursorStartX = ImGui.GetCursorPosX(); @@ -437,7 +624,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase for (var i = 0; i < messageCount; i++) { - var messageHeight = MeasureMessageHeight(channel, channel.Messages[i], showTimestamps, cursorStartX, contentMaxX, itemSpacing, ref pairSnapshot); + var messageHeight = MeasureMessageHeight(channel, channel.Messages[i], showTimestamps, cursorStartX, contentMaxX, itemSpacing, mentionHighlightData, ref pairSnapshot); if (messageHeight <= 0f) { messageHeight = lineHeightWithSpacing; @@ -511,6 +698,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.BeginGroup(); ImGui.PushStyleColor(ImGuiCol.Text, color); + var mentionContextOpen = false; if (showRoleIcons) { if (!string.IsNullOrEmpty(timestampText)) @@ -557,12 +745,12 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } var messageStartX = ImGui.GetCursorPosX(); - DrawChatMessageWithEmotes($"{message.DisplayName}: ", payload.Message, messageStartX); + mentionContextOpen = DrawChatMessageWithEmotes($"{message.DisplayName}: ", payload.Message, messageStartX, mentionHighlightData); } else { var messageStartX = ImGui.GetCursorPosX(); - DrawChatMessageWithEmotes($"{timestampText}{message.DisplayName}: ", payload.Message, messageStartX); + mentionContextOpen = DrawChatMessageWithEmotes($"{timestampText}{message.DisplayName}: ", payload.Message, messageStartX, mentionHighlightData); } ImGui.PopStyleColor(); ImGui.EndGroup(); @@ -570,7 +758,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.SetNextWindowSizeConstraints( new Vector2(190f * ImGuiHelpers.GlobalScale, 0f), new Vector2(float.MaxValue, float.MaxValue)); - if (ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}")) + var messagePopupFlags = ImGuiPopupFlags.MouseButtonRight | ImGuiPopupFlags.NoOpenOverExistingPopup; + if (!mentionContextOpen && ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}", messagePopupFlags)) { var contextLocalTimestamp = payload.SentAtUtc.ToLocalTime(); var contextTimestampText = contextLocalTimestamp.ToString("yyyy-MM-dd HH:mm:ss 'UTC'z", CultureInfo.InvariantCulture); @@ -619,12 +808,15 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } } - private void DrawChatMessageWithEmotes(string prefix, string message, float lineStartX) + private bool DrawChatMessageWithEmotes(string prefix, string message, float lineStartX, MentionHighlightData? mentionHighlightData) { - var segments = BuildChatSegments(prefix, message); + var segments = BuildChatSegments(prefix, message, mentionHighlightData); var firstOnLine = true; - var emoteSize = new Vector2(ImGui.GetTextLineHeight()); + var emoteSizeValue = ImGui.GetTextLineHeight() * GetEmoteScale(); + var emoteSize = new Vector2(emoteSizeValue); var remainingWidth = ImGui.GetContentRegionAvail().X; + var mentionIndex = 0; + var mentionContextOpen = false; foreach (var segment in segments) { @@ -674,13 +866,102 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } else { - ImGui.TextUnformatted(segment.Text); + if (segment.IsMention) + { + Vector4 mentionColor = segment.IsSelfMention + ? UIColors.Get("LightlessYellow") + : UIColors.Get("LightlessPurple"); + ImGui.PushStyleColor(ImGuiCol.Text, mentionColor); + ImGui.TextUnformatted(segment.Text); + ImGui.PopStyleColor(); + mentionContextOpen |= DrawMentionContextMenu(segment.Text, mentionHighlightData, mentionIndex++); + } + else + { + ImGui.TextUnformatted(segment.Text); + } } remainingWidth -= segmentWidth; firstOnLine = false; } + return mentionContextOpen; + } + + private bool DrawMentionContextMenu(string mentionText, MentionHighlightData? mentionHighlightData, int mentionIndex) + { + string token = mentionText; + if (!string.IsNullOrEmpty(token) && token[0] == '@') + { + token = token[1..]; + } + + MentionUserInfo? mentionInfo = null; + if (mentionHighlightData.HasValue + && !string.IsNullOrWhiteSpace(token) + && mentionHighlightData.Value.Users.TryGetValue(token, out var userInfo)) + { + mentionInfo = userInfo; + } + + string statusLabel = "Unknown"; + bool canViewProfile = false; + Action? viewProfileAction = null; + + if (mentionInfo.HasValue) + { + var info = mentionInfo.Value; + if (info.IsSelf) + { + statusLabel = "You"; + } + else if (info.Pair is not null) + { + statusLabel = info.Pair.IsOnline ? "Online" : "Offline"; + } + + if (info.Pair is not null) + { + canViewProfile = true; + viewProfileAction = () => Mediator.Publish(new ProfileOpenStandaloneMessage(info.Pair)); + } + else if (info.UserData is not null) + { + canViewProfile = true; + var userData = info.UserData; + viewProfileAction = () => RunContextAction(() => OpenStandardProfileAsync(userData)); + } + } + + var style = ImGui.GetStyle(); + var iconWidth = _uiSharedService.GetIconSize(FontAwesomeIcon.User).X; + var actionWidth = ImGui.CalcTextSize("View Profile").X + iconWidth + style.ItemSpacing.X; + var baseWidth = MathF.Max( + MathF.Max(ImGui.CalcTextSize(mentionText).X, ImGui.CalcTextSize(statusLabel).X), + actionWidth); + var targetWidth = (baseWidth + style.WindowPadding.X * 2f + style.FramePadding.X * 2f) * 1.5f; + ImGui.SetNextWindowSizeConstraints(new Vector2(targetWidth, 0f), new Vector2(float.MaxValue, float.MaxValue)); + + if (!ImGui.BeginPopupContextItem($"mention_ctx##{mentionIndex}")) + { + return false; + } + + ImGui.TextUnformatted(mentionText); + ImGui.Separator(); + ImGui.TextDisabled(statusLabel); + ImGui.Separator(); + + var profileAction = new ChatMessageContextAction( + FontAwesomeIcon.User, + "View Profile", + canViewProfile, + viewProfileAction ?? NoopContextAction); + DrawContextMenuAction(profileAction, 0); + + ImGui.EndPopup(); + return true; } private void DrawEmotePickerPopup(ref string draft, string channelKey) @@ -817,15 +1098,15 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } } - private List BuildChatSegments(string prefix, string message) + private List BuildChatSegments(string prefix, string message, MentionHighlightData? mentionHighlightData) { var segments = new List(Math.Max(16, message.Length / 4)); - AppendChatSegments(segments, prefix, allowEmotes: false); - AppendChatSegments(segments, message, allowEmotes: true); + AppendChatSegments(segments, prefix, allowEmotes: false, mentionHighlightData: null); + AppendChatSegments(segments, message, allowEmotes: true, mentionHighlightData); return segments; } - private void AppendChatSegments(List segments, string text, bool allowEmotes) + private void AppendChatSegments(List segments, string text, bool allowEmotes, MentionHighlightData? mentionHighlightData) { if (string.IsNullOrEmpty(text)) { @@ -867,6 +1148,23 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } var token = text[tokenStart..index]; + if (mentionHighlightData.HasValue + && TrySplitMentionToken(token, mentionHighlightData.Value, out var leadingMention, out var mentionText, out var trailingMention, out var isSelfMention)) + { + if (!string.IsNullOrEmpty(leadingMention)) + { + segments.Add(ChatSegment.FromText(leadingMention)); + } + + segments.Add(ChatSegment.Mention(mentionText, isSelfMention)); + + if (!string.IsNullOrEmpty(trailingMention)) + { + segments.Add(ChatSegment.FromText(trailingMention)); + } + + continue; + } if (allowEmotes && TrySplitToken(token, out var leading, out var core, out var trailing)) { if (_chatEmoteService.TryGetEmote(core, out var texture) && texture is not null) @@ -925,6 +1223,451 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '!' || value == '(' || value == ')'; } + private static bool TrySplitMentionToken(string token, MentionHighlightData mentionHighlightData, out string leading, out string mentionText, out string trailing, out bool isSelfMention) + { + leading = string.Empty; + mentionText = string.Empty; + trailing = string.Empty; + isSelfMention = false; + + if (string.IsNullOrEmpty(token) || mentionHighlightData.Tokens.Count == 0) + { + return false; + } + + for (int index = 0; index < token.Length; index++) + { + if (token[index] != '@') + { + continue; + } + + if (index > 0 && IsMentionChar(token[index - 1])) + { + continue; + } + + int start = index + 1; + int end = start; + while (end < token.Length && IsMentionChar(token[end])) + { + end++; + } + + if (end == start) + { + continue; + } + + string mentionToken = token[start..end]; + if (!mentionHighlightData.Tokens.TryGetValue(mentionToken, out bool matchedSelf)) + { + continue; + } + + leading = token[..index]; + mentionText = "@" + mentionToken; + trailing = token[end..]; + isSelfMention = matchedSelf; + return true; + } + + return false; + } + + private static bool IsMentionChar(char value) + { + return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '\''; + } + + private static bool IsMentionToken(ReadOnlySpan token, bool allowEmpty) + { + if (token.Length == 0) + { + return allowEmpty; + } + + for (int i = 0; i < token.Length; i++) + { + if (!IsMentionChar(token[i])) + { + return false; + } + } + + return true; + } + + private static bool TryGetMentionQuery(string text, out MentionQuery mentionQuery) + { + mentionQuery = default; + if (string.IsNullOrEmpty(text)) + { + return false; + } + + int cursor = text.Length; + int index = cursor - 1; + while (index >= 0) + { + char current = text[index]; + if (current == '@') + { + if (index > 0 && IsMentionChar(text[index - 1])) + { + return false; + } + + ReadOnlySpan tokenSpan = text.AsSpan(index + 1, cursor - (index + 1)); + if (!IsMentionToken(tokenSpan, allowEmpty: true)) + { + return false; + } + + mentionQuery = new MentionQuery(index, cursor, tokenSpan.ToString()); + return true; + } + + if (char.IsWhiteSpace(current)) + { + return false; + } + + if (!IsMentionChar(current)) + { + return false; + } + + index--; + } + + return false; + } + + private static string? GetPreferredMentionToken(string uid, string? alias) + { + if (!string.IsNullOrWhiteSpace(alias) && IsMentionToken(alias.AsSpan(), allowEmpty: false)) + { + return alias; + } + + if (IsMentionToken(uid.AsSpan(), allowEmpty: false)) + { + return uid; + } + + return null; + } + + private static void AddMentionToken(Dictionary tokens, string token, bool isSelf) + { + if (tokens.TryGetValue(token, out bool existing)) + { + if (isSelf && !existing) + { + tokens[token] = true; + } + + return; + } + + tokens[token] = isSelf; + } + + private static void AddMentionUserToken( + Dictionary users, + HashSet ambiguousTokens, + string token, + MentionUserInfo info) + { + if (ambiguousTokens.Contains(token)) + { + return; + } + + if (users.TryGetValue(token, out var existing)) + { + if (!string.Equals(existing.Uid, info.Uid, StringComparison.Ordinal)) + { + users.Remove(token); + ambiguousTokens.Add(token); + } + + return; + } + + users[token] = info; + } + + private static void AddMentionData( + Dictionary tokens, + Dictionary users, + HashSet ambiguousTokens, + string uid, + string? alias, + bool isSelf, + Pair? pair, + UserData? userData) + { + if (string.IsNullOrWhiteSpace(uid)) + { + return; + } + + var info = new MentionUserInfo(uid, userData, pair, isSelf); + if (IsMentionToken(uid.AsSpan(), allowEmpty: false)) + { + AddMentionToken(tokens, uid, isSelf); + AddMentionUserToken(users, ambiguousTokens, uid, info); + } + + if (!string.IsNullOrWhiteSpace(alias) && IsMentionToken(alias.AsSpan(), allowEmpty: false)) + { + AddMentionToken(tokens, alias, isSelf); + AddMentionUserToken(users, ambiguousTokens, alias, info); + } + } + + private static IReadOnlyList GetPairsForGroup(PairUiSnapshot snapshot, string groupId, GroupFullInfoDto? groupInfo) + { + if (groupInfo is not null && snapshot.GroupPairs.TryGetValue(groupInfo, out IReadOnlyList groupPairs)) + { + return groupPairs; + } + + foreach (KeyValuePair> entry in snapshot.GroupPairs) + { + if (string.Equals(entry.Key.Group.GID, groupId, StringComparison.Ordinal)) + { + return entry.Value; + } + } + + return Array.Empty(); + } + + private void AddMentionCandidate(List candidates, HashSet seenTokens, string uid, string? alias, string? note, bool isSelf, bool includeSelf) + { + if (!includeSelf && isSelf) + { + return; + } + + string? token = GetPreferredMentionToken(uid, alias); + if (string.IsNullOrWhiteSpace(token)) + { + return; + } + + if (!seenTokens.Add(token)) + { + return; + } + + string displayName = !string.IsNullOrWhiteSpace(alias) ? alias : uid; + candidates.Add(new MentionCandidate(token, displayName, note, uid, isSelf)); + } + + private List BuildMentionCandidates(ChatChannelSnapshot channel, PairUiSnapshot snapshot, bool includeSelf) + { + List candidates = new(); + if (channel.Type != ChatChannelType.Group) + { + return candidates; + } + + string? groupId = channel.Descriptor.CustomKey; + if (string.IsNullOrWhiteSpace(groupId)) + { + return candidates; + } + + HashSet seenTokens = new(StringComparer.OrdinalIgnoreCase); + string selfUid = _apiController.UID; + + GroupFullInfoDto? groupInfo = null; + if (snapshot.GroupsByGid.TryGetValue(groupId, out GroupFullInfoDto found)) + { + groupInfo = found; + } + + if (groupInfo is not null) + { + bool ownerIsSelf = string.Equals(groupInfo.Owner.UID, selfUid, StringComparison.Ordinal); + string? ownerNote = _serverConfigurationManager.GetNoteForUid(groupInfo.Owner.UID); + AddMentionCandidate(candidates, seenTokens, groupInfo.Owner.UID, groupInfo.Owner.Alias, ownerNote, ownerIsSelf, includeSelf); + + IReadOnlyList groupPairs = GetPairsForGroup(snapshot, groupId, groupInfo); + foreach (Pair pair in groupPairs) + { + bool isSelf = string.Equals(pair.UserData.UID, selfUid, StringComparison.Ordinal); + string? note = pair.GetNote(); + AddMentionCandidate(candidates, seenTokens, pair.UserData.UID, pair.UserData.Alias, note, isSelf, includeSelf); + } + } + else + { + IReadOnlyList groupPairs = GetPairsForGroup(snapshot, groupId, null); + foreach (Pair pair in groupPairs) + { + bool isSelf = string.Equals(pair.UserData.UID, selfUid, StringComparison.Ordinal); + string? note = pair.GetNote(); + AddMentionCandidate(candidates, seenTokens, pair.UserData.UID, pair.UserData.Alias, note, isSelf, includeSelf); + } + } + + if (includeSelf) + { + string? note = _serverConfigurationManager.GetNoteForUid(selfUid); + AddMentionCandidate(candidates, seenTokens, selfUid, _apiController.DisplayName, note, isSelf: true, includeSelf: true); + } + + return candidates; + } + + private MentionHighlightData? BuildMentionHighlightData(ChatChannelSnapshot channel, PairUiSnapshot snapshot) + { + if (channel.Type != ChatChannelType.Group) + { + return null; + } + + string? groupId = channel.Descriptor.CustomKey; + if (string.IsNullOrWhiteSpace(groupId)) + { + return null; + } + + Dictionary tokens = new(StringComparer.OrdinalIgnoreCase); + Dictionary users = new(StringComparer.OrdinalIgnoreCase); + HashSet ambiguousTokens = new(StringComparer.OrdinalIgnoreCase); + string selfUid = _apiController.UID; + if (!string.IsNullOrWhiteSpace(selfUid)) + { + var selfData = new UserData(selfUid, _apiController.DisplayName); + snapshot.PairsByUid.TryGetValue(selfUid, out var selfPair); + AddMentionData(tokens, users, ambiguousTokens, selfUid, _apiController.DisplayName, true, selfPair, selfData); + } + + GroupFullInfoDto? groupInfo = null; + if (snapshot.GroupsByGid.TryGetValue(groupId, out GroupFullInfoDto found)) + { + groupInfo = found; + } + + if (groupInfo is not null) + { + bool ownerIsSelf = string.Equals(groupInfo.Owner.UID, selfUid, StringComparison.Ordinal); + var ownerUid = groupInfo.Owner.UID; + snapshot.PairsByUid.TryGetValue(ownerUid, out var ownerPair); + AddMentionData(tokens, users, ambiguousTokens, ownerUid, groupInfo.Owner.Alias, ownerIsSelf, ownerPair, groupInfo.Owner); + + IReadOnlyList groupPairs = GetPairsForGroup(snapshot, groupId, groupInfo); + foreach (Pair pair in groupPairs) + { + bool isSelf = string.Equals(pair.UserData.UID, selfUid, StringComparison.Ordinal); + AddMentionData(tokens, users, ambiguousTokens, pair.UserData.UID, pair.UserData.Alias, isSelf, pair, pair.UserData); + } + } + else + { + IReadOnlyList groupPairs = GetPairsForGroup(snapshot, groupId, null); + foreach (Pair pair in groupPairs) + { + bool isSelf = string.Equals(pair.UserData.UID, selfUid, StringComparison.Ordinal); + AddMentionData(tokens, users, ambiguousTokens, pair.UserData.UID, pair.UserData.Alias, isSelf, pair, pair.UserData); + } + } + + if (tokens.Count == 0) + { + return null; + } + + return new MentionHighlightData(tokens, users); + } + + private static List FilterMentionCandidates(IEnumerable candidates, string query) + { + string trimmed = query.Trim(); + IEnumerable filtered = candidates; + + if (trimmed.Length > 0) + { + filtered = filtered.Where(candidate => + candidate.Token.Contains(trimmed, StringComparison.OrdinalIgnoreCase) + || candidate.DisplayName.Contains(trimmed, StringComparison.OrdinalIgnoreCase) + || candidate.Uid.Contains(trimmed, StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrWhiteSpace(candidate.Note) && candidate.Note.Contains(trimmed, StringComparison.OrdinalIgnoreCase))); + } + + List result = filtered + .OrderBy(candidate => string.IsNullOrWhiteSpace(candidate.Note) ? candidate.DisplayName : candidate.Note, StringComparer.OrdinalIgnoreCase) + .ThenBy(candidate => candidate.DisplayName, StringComparer.OrdinalIgnoreCase) + .Take(MaxMentionSuggestions) + .ToList(); + return result; + } + + private static string BuildMentionLabel(MentionCandidate candidate) + { + string label = candidate.DisplayName; + if (!string.IsNullOrWhiteSpace(candidate.Note) && !string.Equals(candidate.Note, candidate.DisplayName, StringComparison.OrdinalIgnoreCase)) + { + label = $"{candidate.Note} ({label})"; + } + + if (!string.Equals(candidate.Token, candidate.DisplayName, StringComparison.OrdinalIgnoreCase)) + { + label = $"{label} [{candidate.Token}]"; + } + + return label; + } + + private static string ApplyMentionToDraft(string draft, MentionQuery mentionQuery, string token, int maxLength, out int cursorPos) + { + string before = mentionQuery.StartIndex > 0 ? draft[..mentionQuery.StartIndex] : string.Empty; + string after = mentionQuery.EndIndex < draft.Length ? draft[mentionQuery.EndIndex..] : string.Empty; + string mentionText = "@" + token; + + if (string.IsNullOrEmpty(after) || !char.IsWhiteSpace(after[0])) + { + mentionText += " "; + } + + string updated = before + mentionText + after; + if (updated.Length > maxLength) + { + updated = updated[..maxLength]; + } + + cursorPos = Math.Min(before.Length + mentionText.Length, updated.Length); + return updated; + } + + private unsafe int ChatInputCallback(ref ImGuiInputTextCallbackData data) + { + if (_pendingDraftCursorPos < 0) + { + return 0; + } + + if (!string.Equals(_pendingDraftCursorChannelKey, _activeInputChannelKey, StringComparison.Ordinal)) + { + return 0; + } + + int clampedCursor = Math.Clamp(_pendingDraftCursorPos, 0, data.BufTextLen); + data.CursorPos = clampedCursor; + data.SelectionStart = clampedCursor; + data.SelectionEnd = clampedCursor; + + _pendingDraftCursorPos = -1; + _pendingDraftCursorChannelKey = null; + return 0; + } + private float MeasureMessageHeight( ChatChannelSnapshot channel, ChatMessageEntry message, @@ -932,6 +1675,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase float cursorStartX, float contentMaxX, float itemSpacing, + MentionHighlightData? mentionHighlightData, ref PairUiSnapshot? pairSnapshot) { if (message.IsSystem) @@ -988,29 +1732,32 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase prefix = $"{timestampText}{message.DisplayName}: "; } - var lines = MeasureChatMessageLines(prefix, payload.Message, lineStartX, contentMaxX); - return Math.Max(1, lines) * ImGui.GetTextLineHeightWithSpacing(); + return MeasureChatMessageHeight(prefix, payload.Message, lineStartX, contentMaxX, mentionHighlightData); } - private int MeasureChatMessageLines(string prefix, string message, float lineStartX, float contentMaxX) + private float MeasureChatMessageHeight(string prefix, string message, float lineStartX, float contentMaxX, MentionHighlightData? mentionHighlightData) { - var segments = BuildChatSegments(prefix, message); + var segments = BuildChatSegments(prefix, message, mentionHighlightData); if (segments.Count == 0) { - return 1; + return ImGui.GetTextLineHeightWithSpacing(); } - var emoteWidth = ImGui.GetTextLineHeight(); + var baseLineHeight = ImGui.GetTextLineHeight(); + var emoteSize = baseLineHeight * GetEmoteScale(); + var spacingY = ImGui.GetStyle().ItemSpacing.Y; var availableWidth = Math.Max(1f, contentMaxX - lineStartX); var remainingWidth = availableWidth; var firstOnLine = true; - var lines = 1; + var lineHeight = baseLineHeight; + var totalHeight = 0f; foreach (var segment in segments) { if (segment.IsLineBreak) { - lines++; + totalHeight += lineHeight + spacingY; + lineHeight = baseLineHeight; firstOnLine = true; remainingWidth = availableWidth; continue; @@ -1021,12 +1768,13 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase continue; } - var segmentWidth = segment.IsEmote ? emoteWidth : ImGui.CalcTextSize(segment.Text).X; + var segmentWidth = segment.IsEmote ? emoteSize : ImGui.CalcTextSize(segment.Text).X; if (!firstOnLine) { if (segmentWidth > remainingWidth) { - lines++; + totalHeight += lineHeight + spacingY; + lineHeight = baseLineHeight; firstOnLine = true; remainingWidth = availableWidth; if (segment.IsWhitespace) @@ -1036,11 +1784,17 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } } + if (segment.IsEmote) + { + lineHeight = MathF.Max(lineHeight, emoteSize); + } + remainingWidth -= segmentWidth; firstOnLine = false; } - return lines; + totalHeight += lineHeight + spacingY; + return totalHeight; } private float MeasureRolePrefixWidth(string timestampText, bool isOwner, bool isModerator, bool isPinned, float itemSpacing) @@ -1089,6 +1843,9 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase return width; } + private float GetEmoteScale() + => Math.Clamp(_chatConfigService.Current.EmoteScale, MinEmoteScale, MaxEmoteScale); + private float MeasureIconWidth(FontAwesomeIcon icon) { using var font = _uiSharedService.IconFont.Push(); @@ -1179,11 +1936,17 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.EndTooltip(); } - private readonly record struct ChatSegment(string Text, IDalamudTextureWrap? Texture, string? EmoteName, bool IsEmote, bool IsWhitespace, bool IsLineBreak) + private readonly record struct MentionQuery(int StartIndex, int EndIndex, string Token); + private readonly record struct MentionCandidate(string Token, string DisplayName, string? Note, string Uid, bool IsSelf); + private readonly record struct MentionUserInfo(string Uid, UserData? UserData, Pair? Pair, bool IsSelf); + private readonly record struct MentionHighlightData(IReadOnlyDictionary Tokens, IReadOnlyDictionary Users); + + private readonly record struct ChatSegment(string Text, IDalamudTextureWrap? Texture, string? EmoteName, bool IsEmote, bool IsWhitespace, bool IsLineBreak, bool IsMention, bool IsSelfMention) { - public static ChatSegment FromText(string text, bool isWhitespace = false) => new(text, null, null, false, isWhitespace, false); - public static ChatSegment Emote(IDalamudTextureWrap texture, string name) => new(string.Empty, texture, name, true, false, false); - public static ChatSegment LineBreak() => new(string.Empty, null, null, false, false, true); + public static ChatSegment FromText(string text, bool isWhitespace = false) => new(text, null, null, false, isWhitespace, false, false, false); + public static ChatSegment Emote(IDalamudTextureWrap texture, string name) => new(string.Empty, texture, name, true, false, false, false, false); + public static ChatSegment LineBreak() => new(string.Empty, null, null, false, false, true, false, false); + public static ChatSegment Mention(string text, bool isSelfMention) => new(text, null, null, false, false, false, true, isSelfMention); } private void DrawInput(ChatChannelSnapshot channel) @@ -1207,18 +1970,152 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase _refocusChatInput = false; _refocusChatInputKey = null; } - ImGui.InputText(inputId, ref draft, MaxMessageLength); - if (ImGui.IsItemActive()) + _activeInputChannelKey = channel.Key; + ImGui.InputText(inputId, ref draft, MaxMessageLength, ImGuiInputTextFlags.CallbackAlways, ChatInputCallback); + _activeInputChannelKey = null; + Vector2 inputMin = ImGui.GetItemRectMin(); + Vector2 inputMax = ImGui.GetItemRectMax(); + bool inputActive = ImGui.IsItemActive(); + if (inputActive) { var drawList = ImGui.GetWindowDrawList(); - var itemMin = ImGui.GetItemRectMin(); - var itemMax = ImGui.GetItemRectMax(); var highlight = UIColors.Get("LightlessPurple").WithAlpha(0.35f); var highlightU32 = ImGui.ColorConvertFloat4ToU32(highlight); - drawList.AddRect(itemMin, itemMax, highlightU32, style.FrameRounding, ImDrawFlags.None, Math.Max(1f, ImGuiHelpers.GlobalScale)); + drawList.AddRect(inputMin, inputMax, highlightU32, style.FrameRounding, ImDrawFlags.None, Math.Max(1f, ImGuiHelpers.GlobalScale)); } var enterPressed = ImGui.IsItemFocused() && (ImGui.IsKeyPressed(ImGuiKey.Enter) || ImGui.IsKeyPressed(ImGuiKey.KeypadEnter)); + bool mentionHandled = false; + bool showMentionPopup = false; + bool popupAlreadyOpen = ImGui.IsPopupOpen(MentionPopupId, ImGuiPopupFlags.AnyPopupLevel); + bool mentionContextActive = (inputActive || popupAlreadyOpen) && channel.Type == ChatChannelType.Group; + if (mentionContextActive) + { + if (TryGetMentionQuery(draft, out MentionQuery mentionQuery)) + { + PairUiSnapshot mentionSnapshot = _pairUiService.GetSnapshot(); + List mentionCandidates = BuildMentionCandidates(channel, mentionSnapshot, includeSelf: false); + List filteredCandidates = FilterMentionCandidates(mentionCandidates, mentionQuery.Token); + + if (filteredCandidates.Count > 0) + { + string mentionSelectionKey = $"{channel.Key}:{mentionQuery.Token}"; + if (!string.Equals(_mentionSelectionKey, mentionSelectionKey, StringComparison.Ordinal)) + { + _mentionSelectionKey = mentionSelectionKey; + _mentionSelectionIndex = 0; + } + else + { + _mentionSelectionIndex = Math.Clamp(_mentionSelectionIndex, 0, filteredCandidates.Count - 1); + } + + if (ImGui.IsKeyPressed(ImGuiKey.DownArrow)) + { + _mentionSelectionIndex = Math.Min(_mentionSelectionIndex + 1, filteredCandidates.Count - 1); + } + + if (ImGui.IsKeyPressed(ImGuiKey.UpArrow)) + { + _mentionSelectionIndex = Math.Max(_mentionSelectionIndex - 1, 0); + } + + if (enterPressed || ImGui.IsKeyPressed(ImGuiKey.Tab)) + { + int selectedIndex = Math.Clamp(_mentionSelectionIndex, 0, filteredCandidates.Count - 1); + MentionCandidate selected = filteredCandidates[selectedIndex]; + draft = ApplyMentionToDraft(draft, mentionQuery, selected.Token, MaxMessageLength, out int cursorPos); + _pendingDraftCursorPos = cursorPos; + _pendingDraftCursorChannelKey = channel.Key; + _refocusChatInput = true; + _refocusChatInputKey = channel.Key; + enterPressed = false; + mentionHandled = true; + } + + bool popupRequested = inputActive && !mentionHandled; + showMentionPopup = popupRequested || popupAlreadyOpen; + if (showMentionPopup) + { + float popupWidth = Math.Max(260f * ImGuiHelpers.GlobalScale, inputMax.X - inputMin.X); + ImGui.SetNextWindowPos(new Vector2(inputMin.X, inputMax.Y + style.ItemSpacing.Y), ImGuiCond.Always); + ImGui.SetNextWindowSizeConstraints(new Vector2(popupWidth, 0f), new Vector2(popupWidth, float.MaxValue)); + } + + if (popupRequested && !popupAlreadyOpen) + { + ImGui.OpenPopup(MentionPopupId); + } + + const ImGuiWindowFlags mentionPopupFlags = ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoNavFocus | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoSavedSettings; + if (showMentionPopup && ImGui.BeginPopup(MentionPopupId, mentionPopupFlags)) + { + float lineHeight = ImGui.GetTextLineHeightWithSpacing(); + int visibleEntries = Math.Min(3, filteredCandidates.Count); + float desiredHeight = lineHeight * visibleEntries; + using (ImRaii.Child("##mention_list", new Vector2(-1f, desiredHeight), true)) + { + for (int i = 0; i < filteredCandidates.Count; i++) + { + MentionCandidate candidate = filteredCandidates[i]; + string label = BuildMentionLabel(candidate); + bool isSelected = i == _mentionSelectionIndex; + if (ImGui.Selectable(label, isSelected)) + { + draft = ApplyMentionToDraft(draft, mentionQuery, candidate.Token, MaxMessageLength, out int cursorPos); + _pendingDraftCursorPos = cursorPos; + _pendingDraftCursorChannelKey = channel.Key; + _refocusChatInput = true; + _refocusChatInputKey = channel.Key; + enterPressed = false; + mentionHandled = true; + ImGui.CloseCurrentPopup(); + break; + } + + if (ImGui.IsItemHovered()) + { + _mentionSelectionIndex = i; + } + } + } + + ImGui.EndPopup(); + } + } + else + { + _mentionSelectionKey = null; + _mentionSelectionIndex = -1; + if (popupAlreadyOpen && ImGui.BeginPopup(MentionPopupId)) + { + ImGui.CloseCurrentPopup(); + ImGui.EndPopup(); + } + } + } + else + { + _mentionSelectionKey = null; + _mentionSelectionIndex = -1; + if (popupAlreadyOpen && ImGui.BeginPopup(MentionPopupId)) + { + ImGui.CloseCurrentPopup(); + ImGui.EndPopup(); + } + } + } + else + { + _mentionSelectionKey = null; + _mentionSelectionIndex = -1; + if (popupAlreadyOpen && ImGui.BeginPopup(MentionPopupId)) + { + ImGui.CloseCurrentPopup(); + ImGui.EndPopup(); + } + } + _draftMessages[channel.Key] = draft; ImGui.SameLine(); @@ -1586,6 +2483,25 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase public override void PostDraw() { + if (_forceExpandOnOpen && IsOpen) + { + Collapsed = null; + _forceExpandOnOpen = false; + } + if (IsOpen) + { + var metricsUpdated = TryUpdateWindowMetricsFromBase(); + if (metricsUpdated) + { + var isCollapsed = IsLikelyCollapsed(_lastWindowSize); + UpdateCollapsedState(isCollapsed); + } + + if (_isWindowCollapsed && _collapsedMessageCount > 0 && _hasWindowMetrics) + { + DrawCollapsedMessageBadge(ImGui.GetForegroundDrawList(), _lastWindowPos, _lastWindowSize); + } + } if (_pushedStyle) { ImGui.PopStyleVar(1); @@ -1975,13 +2891,59 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase }); } + private void HandleIncomingMessageForCollapsedBadge(ChatMessageEntry message) + { + if (!IsCountableIncomingMessage(message)) + { + return; + } + + var config = _chatConfigService.Current; + if (!IsOpen) + { + if (config.AutoOpenChatOnNewMessage && !ShouldHide()) + { + IsOpen = true; + Collapsed = false; + CollapsedCondition = ImGuiCond.Appearing; + _forceExpandOnOpen = true; + } + + return; + } + + if (_isWindowCollapsed) + { + if (_collapsedMessageCount < CollapsedMessageCountDisplayCap + 1) + { + _collapsedMessageCount++; + } + } + } + + private static bool IsCountableIncomingMessage(ChatMessageEntry message) + { + if (message.FromSelf || message.IsSystem) + { + return false; + } + + return message.Payload?.Message is { Length: > 0 }; + } + private void OnChatChannelMessageAdded(ChatChannelMessageAdded message) { - if (_selectedChannelKey is not null && string.Equals(_selectedChannelKey, message.ChannelKey, StringComparison.Ordinal)) + var channelHidden = IsChannelHidden(message.ChannelKey); + if (!channelHidden && _selectedChannelKey is not null && string.Equals(_selectedChannelKey, message.ChannelKey, StringComparison.Ordinal)) { _scrollToBottom = true; } + if (!channelHidden) + { + HandleIncomingMessageForCollapsedBadge(message.Message); + } + if (!message.Message.FromSelf || message.Message.Payload?.Message is not { Length: > 0 } payloadText) { return; @@ -2092,9 +3054,10 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase if (_selectedChannelKey is not null && channels.Any(channel => string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal))) return; - _selectedChannelKey = channels.Count > 0 ? channels[0].Key : null; - if (_selectedChannelKey is not null) + string? nextKey = channels.Count > 0 ? channels[0].Key : null; + if (!string.Equals(_selectedChannelKey, nextKey, StringComparison.Ordinal)) { + _selectedChannelKey = nextKey; _zoneChatService.SetActiveChannel(_selectedChannelKey); _scrollToBottom = true; } @@ -2118,6 +3081,43 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } } + private IReadOnlyList GetVisibleChannels(IReadOnlyList channels) + { + Dictionary hiddenChannels = _chatConfigService.Current.HiddenChannels; + if (hiddenChannels.Count == 0) + { + return channels; + } + + List visibleChannels = new List(channels.Count); + foreach (var channel in channels) + { + if (!hiddenChannels.TryGetValue(channel.Key, out var isHidden) || !isHidden) + { + visibleChannels.Add(channel); + } + } + + return visibleChannels; + } + + private bool IsChannelHidden(string channelKey) + => _chatConfigService.Current.HiddenChannels.TryGetValue(channelKey, out var isHidden) && isHidden; + + private void SetChannelHidden(string channelKey, bool hidden) + { + if (hidden) + { + _chatConfigService.Current.HiddenChannels[channelKey] = true; + } + else + { + _chatConfigService.Current.HiddenChannels.Remove(channelKey); + } + + _chatConfigService.Save(); + } + private void DrawConnectionControls() { var hubState = _apiController.ServerState; @@ -2289,14 +3289,50 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private void DrawChatSettingsPopup() { const ImGuiWindowFlags popupFlags = ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoSavedSettings; + var workSize = ImGui.GetMainViewport().WorkSize; + var minWidth = MathF.Min(420f * ImGuiHelpers.GlobalScale, workSize.X * 0.9f); + var minHeight = MathF.Min(360f * ImGuiHelpers.GlobalScale, workSize.Y * 0.85f); + var minSize = new Vector2(minWidth, minHeight); + var maxSize = new Vector2( + MathF.Max(minSize.X, workSize.X * 0.95f), + MathF.Max(minSize.Y, workSize.Y * 0.95f)); + ImGui.SetNextWindowSizeConstraints(minSize, maxSize); + ImGui.SetNextWindowSize(minSize, ImGuiCond.Appearing); if (!ImGui.BeginPopup(SettingsPopupId, popupFlags)) return; ImGui.TextUnformatted("Chat Settings"); ImGui.Separator(); + UiSharedService.Tab("ChatSettingsTabs", ChatSettingsTabOptions, ref _selectedChatSettingsTab); + ImGuiHelpers.ScaledDummy(5); + var chatConfig = _chatConfigService.Current; + switch (_selectedChatSettingsTab) + { + case ChatSettingsTab.General: + DrawChatSettingsGeneral(chatConfig); + break; + case ChatSettingsTab.Messages: + DrawChatSettingsMessages(chatConfig); + break; + case ChatSettingsTab.Notifications: + DrawChatSettingsNotifications(chatConfig); + break; + case ChatSettingsTab.Visibility: + DrawChatSettingsVisibility(chatConfig); + break; + case ChatSettingsTab.Window: + DrawChatSettingsWindow(chatConfig); + break; + } + + ImGui.EndPopup(); + } + + private void DrawChatSettingsGeneral(ChatConfig chatConfig) + { var autoEnable = chatConfig.AutoEnableChatOnLogin; if (ImGui.Checkbox("Auto-enable chat on login", ref autoEnable)) { @@ -2338,6 +3374,28 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase { ImGui.SetTooltip("Toggles if the rules popup appears everytime the chat is opened for the first time."); } + } + + private void DrawChatSettingsMessages(ChatConfig chatConfig) + { + var fontScale = Math.Clamp(chatConfig.ChatFontScale, MinChatFontScale, MaxChatFontScale); + var fontScaleChanged = ImGui.SliderFloat("Message font scale", ref fontScale, MinChatFontScale, MaxChatFontScale, "%.2fx"); + var resetFontScale = ImGui.IsItemClicked(ImGuiMouseButton.Right); + if (resetFontScale) + { + fontScale = 1.0f; + fontScaleChanged = true; + } + + if (fontScaleChanged) + { + chatConfig.ChatFontScale = fontScale; + _chatConfigService.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Adjust scale of chat message text.\nRight-click to reset to default."); + } var showTimestamps = chatConfig.ShowMessageTimestamps; if (ImGui.Checkbox("Show message timestamps", ref showTimestamps)) @@ -2372,8 +3430,116 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.SetTooltip("When disabled, emotes render as static images."); } + var emoteScale = Math.Clamp(chatConfig.EmoteScale, MinEmoteScale, MaxEmoteScale); + var emoteScaleChanged = ImGui.SliderFloat("Emote size", ref emoteScale, MinEmoteScale, MaxEmoteScale, "%.2fx"); + var resetEmoteScale = ImGui.IsItemClicked(ImGuiMouseButton.Right); + if (resetEmoteScale) + { + emoteScale = 1.0f; + emoteScaleChanged = true; + } + + if (emoteScaleChanged) + { + chatConfig.EmoteScale = emoteScale; + _chatConfigService.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Scales emotes relative to text height.\nRight-click to reset to default."); + } + + ImGui.Separator(); + ImGui.TextUnformatted("History"); + ImGui.Separator(); + + bool persistHistory = chatConfig.PersistSyncshellHistory; + if (ImGui.Checkbox("Persist syncshell chat history", ref persistHistory)) + { + chatConfig.PersistSyncshellHistory = persistHistory; + _chatConfigService.Save(); + if (!persistHistory) + { + _zoneChatService.ClearPersistedSyncshellHistory(clearLoadedMessages: false); + } + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Stores the latest 200 syncshell messages on disk and restores them when chat loads.\nStored messages are considered stale and cannot be muted or reported."); + } + + bool hasPersistedHistory = chatConfig.SyncshellChannelHistory.Count > 0; + using (ImRaii.Disabled(!hasPersistedHistory || !UiSharedService.CtrlPressed())) + { + if (ImGui.Button("Clear saved syncshell history")) + { + _zoneChatService.ClearPersistedSyncshellHistory(clearLoadedMessages: true); + } + } + UiSharedService.AttachToolTip("Clears saved syncshell chat history and loaded cached messages." + + UiSharedService.TooltipSeparator + "Hold CTRL to enable this button"); + } + + private void DrawChatSettingsNotifications(ChatConfig chatConfig) + { + var notifyMentions = chatConfig.EnableMentionNotifications; + if (ImGui.Checkbox("Notify on mentions", ref notifyMentions)) + { + chatConfig.EnableMentionNotifications = notifyMentions; + _chatConfigService.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Show a notification when someone mentions you in syncshell chat."); + } + + var autoOpenOnMessage = chatConfig.AutoOpenChatOnNewMessage; + if (ImGui.Checkbox("Auto-open chat on new messages when closed", ref autoOpenOnMessage)) + { + chatConfig.AutoOpenChatOnNewMessage = autoOpenOnMessage; + _chatConfigService.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Reopens the chat window when a new message arrives while it is closed."); + } + } + + private void DrawChatSettingsVisibility(ChatConfig chatConfig) + { + ImGui.TextUnformatted("Channel Visibility"); + ImGui.Separator(); + + IReadOnlyList channels = _zoneChatService.GetChannelsSnapshot(); + if (channels.Count == 0) + { + ImGui.TextDisabled("No chat channels available."); + } + else + { + ImGui.TextDisabled("Uncheck a channel to hide its tab."); + ImGui.TextDisabled("Hidden channels still receive messages."); + + float maxListHeight = 200f * ImGuiHelpers.GlobalScale; + float listHeight = Math.Min(maxListHeight, channels.Count * ImGui.GetFrameHeightWithSpacing() + ImGui.GetStyle().ItemSpacing.Y); + using var child = ImRaii.Child("chat_channel_visibility_list", new Vector2(0f, listHeight), true); + if (child) + { + foreach (var channel in channels) + { + bool isVisible = !IsChannelHidden(channel.Key); + string prefix = channel.Type == ChatChannelType.Zone ? "Zone" : "Syncshell"; + if (ImGui.Checkbox($"{prefix}: {channel.DisplayName}##{channel.Key}", ref isVisible)) + { + SetChannelHidden(channel.Key, !isVisible); + } + } + } + } + ImGui.Separator(); ImGui.TextUnformatted("Chat Visibility"); + ImGui.Separator(); var autoHideCombat = chatConfig.HideInCombat; if (ImGui.Checkbox("Hide in combat", ref autoHideCombat)) @@ -2434,28 +3600,10 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase { ImGui.SetTooltip("Allow the chat window to remain visible in /gpose."); } + } - ImGui.Separator(); - - var fontScale = Math.Clamp(chatConfig.ChatFontScale, MinChatFontScale, MaxChatFontScale); - var fontScaleChanged = ImGui.SliderFloat("Message font scale", ref fontScale, MinChatFontScale, MaxChatFontScale, "%.2fx"); - var resetFontScale = ImGui.IsItemClicked(ImGuiMouseButton.Right); - if (resetFontScale) - { - fontScale = 1.0f; - fontScaleChanged = true; - } - - if (fontScaleChanged) - { - chatConfig.ChatFontScale = fontScale; - _chatConfigService.Save(); - } - if (ImGui.IsItemHovered()) - { - ImGui.SetTooltip("Adjust scale of chat message text.\nRight-click to reset to default."); - } - + private void DrawChatSettingsWindow(ChatConfig chatConfig) + { var windowOpacity = Math.Clamp(chatConfig.ChatWindowOpacity, MinWindowOpacity, MaxWindowOpacity); var opacityChanged = ImGui.SliderFloat("Window transparency", ref windowOpacity, MinWindowOpacity, MaxWindowOpacity, "%.2f"); var resetOpacity = ImGui.IsItemClicked(ImGuiMouseButton.Right); @@ -2484,7 +3632,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } if (ImGui.IsItemHovered()) { - ImGui.SetTooltip("When enabled, the chat window fades after it loses focus.\nHovering the window restores focus."); + ImGui.SetTooltip("When enabled, the chat window fades after it loses focus.\nHovering the window restores opacity."); } ImGui.BeginDisabled(!fadeUnfocused); @@ -2506,8 +3654,6 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.SetTooltip("Target transparency while the chat window is unfocused.\nRight-click to reset to default."); } ImGui.EndDisabled(); - - ImGui.EndPopup(); } private static float MoveTowards(float current, float target, float maxDelta) @@ -2542,27 +3688,49 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private unsafe void DrawChannelButtons(IReadOnlyList channels) { - var style = ImGui.GetStyle(); - var baseFramePadding = style.FramePadding; - var available = ImGui.GetContentRegionAvail().X; - var buttonHeight = ImGui.GetFrameHeight(); - var arrowWidth = buttonHeight; - var hasChannels = channels.Count > 0; - var scrollWidth = hasChannels ? Math.Max(0f, available - (arrowWidth * 2f + style.ItemSpacing.X * 2f)) : 0f; + ImGuiStylePtr style = ImGui.GetStyle(); + Vector2 baseFramePadding = style.FramePadding; + float available = ImGui.GetContentRegionAvail().X; + float buttonHeight = ImGui.GetFrameHeight(); + float arrowWidth = buttonHeight; + bool hasChannels = channels.Count > 0; + float scrollWidth = hasChannels ? Math.Max(0f, available - (arrowWidth * 2f + style.ItemSpacing.X * 2f)) : 0f; if (hasChannels) { - var minimumWidth = 120f * ImGuiHelpers.GlobalScale; + float minimumWidth = 120f * ImGuiHelpers.GlobalScale; scrollWidth = Math.Max(scrollWidth, minimumWidth); } - var scrollStep = scrollWidth > 0f ? scrollWidth * 0.9f : 120f; + float scrollStep = scrollWidth > 0f ? scrollWidth * 0.9f : 120f; + float badgeSpacing = 4f * ImGuiHelpers.GlobalScale; + Vector2 badgePadding = new Vector2(4f, 1.5f) * ImGuiHelpers.GlobalScale; + bool showScrollbar = false; + if (hasChannels) + { + float totalWidth = 0f; + bool firstWidth = true; + foreach (ChatChannelSnapshot channel in channels) + { + if (!firstWidth) + { + totalWidth += style.ItemSpacing.X; + } + + totalWidth += GetChannelTabWidth(channel, baseFramePadding, badgeSpacing, badgePadding); + firstWidth = false; + } + + showScrollbar = totalWidth > scrollWidth; + } + + float childHeight = buttonHeight + style.FramePadding.Y * 2f + (showScrollbar ? style.ScrollbarSize : 0f); if (!hasChannels) { _pendingChannelScroll = null; _channelScroll = 0f; _channelScrollMax = 0f; } - var prevScroll = hasChannels ? _channelScroll : 0f; - var prevMax = hasChannels ? _channelScrollMax : 0f; + float prevScroll = hasChannels ? _channelScroll : 0f; + float prevMax = hasChannels ? _channelScrollMax : 0f; float currentScroll = prevScroll; float maxScroll = prevMax; @@ -2587,7 +3755,6 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.SameLine(0f, style.ItemSpacing.X); - var childHeight = buttonHeight + style.FramePadding.Y * 2f + style.ScrollbarSize; var alignPushed = false; if (hasChannels) { @@ -2595,31 +3762,29 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase alignPushed = true; } - const int MaxBadgeDisplay = 99; - using (var child = ImRaii.Child("channel_scroll", new Vector2(scrollWidth, childHeight), false, ImGuiWindowFlags.HorizontalScrollbar)) { if (child) { - var dragActive = _dragChannelKey is not null && ImGui.IsMouseDragging(ImGuiMouseButton.Left); - var hoveredTargetThisFrame = false; - var first = true; + bool dragActive = _dragChannelKey is not null && ImGui.IsMouseDragging(ImGuiMouseButton.Left); + bool hoveredTargetThisFrame = false; + bool first = true; foreach (var channel in channels) { if (!first) ImGui.SameLine(); - var isSelected = string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal); - var showBadge = !isSelected && channel.UnreadCount > 0; - var isZoneChannel = channel.Type == ChatChannelType.Zone; + bool isSelected = string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal); + bool showBadge = !isSelected && channel.UnreadCount > 0; + bool isZoneChannel = channel.Type == ChatChannelType.Zone; (string Text, Vector2 TextSize, float Width, float Height)? badgeMetrics = null; - var channelLabel = GetChannelTabLabel(channel); + string channelLabel = GetChannelTabLabel(channel); - var normal = isSelected ? UIColors.Get("LightlessPurpleDefault") : UIColors.Get("ButtonDefault"); - var hovered = isSelected + Vector4 normal = isSelected ? UIColors.Get("LightlessPurpleDefault") : UIColors.Get("ButtonDefault"); + Vector4 hovered = isSelected ? UIColors.Get("LightlessPurple").WithAlpha(0.9f) : UIColors.Get("ButtonDefault").WithAlpha(0.85f); - var active = isSelected + Vector4 active = isSelected ? UIColors.Get("LightlessPurpleDefault").WithAlpha(0.8f) : UIColors.Get("ButtonDefault").WithAlpha(0.75f); @@ -2629,20 +3794,18 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase if (showBadge) { - var badgeSpacing = 4f * ImGuiHelpers.GlobalScale; - var badgePadding = new Vector2(4f, 1.5f) * ImGuiHelpers.GlobalScale; - var badgeText = channel.UnreadCount > MaxBadgeDisplay + string badgeText = channel.UnreadCount > MaxBadgeDisplay ? $"{MaxBadgeDisplay}+" : channel.UnreadCount.ToString(CultureInfo.InvariantCulture); - var badgeTextSize = ImGui.CalcTextSize(badgeText); - var badgeWidth = badgeTextSize.X + badgePadding.X * 2f; - var badgeHeight = badgeTextSize.Y + badgePadding.Y * 2f; - var customPadding = new Vector2(baseFramePadding.X + badgeWidth + badgeSpacing, baseFramePadding.Y); + Vector2 badgeTextSize = ImGui.CalcTextSize(badgeText); + float badgeWidth = badgeTextSize.X + badgePadding.X * 2f; + float badgeHeight = badgeTextSize.Y + badgePadding.Y * 2f; + Vector2 customPadding = new Vector2(baseFramePadding.X + badgeWidth + badgeSpacing, baseFramePadding.Y); ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, customPadding); badgeMetrics = (badgeText, badgeTextSize, badgeWidth, badgeHeight); } - var clicked = ImGui.Button($"{channelLabel}##chat_channel_{channel.Key}"); + bool clicked = ImGui.Button($"{channelLabel}##chat_channel_{channel.Key}"); if (showBadge) { @@ -2678,12 +3841,12 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.EndDragDropSource(); } - var isDragTarget = false; + bool isDragTarget = false; if (ImGui.BeginDragDropTarget()) { - var acceptFlags = ImGuiDragDropFlags.AcceptBeforeDelivery | ImGuiDragDropFlags.AcceptNoDrawDefaultRect; - var payload = ImGui.AcceptDragDropPayload(ChannelDragPayloadId, acceptFlags); + ImGuiDragDropFlags acceptFlags = ImGuiDragDropFlags.AcceptBeforeDelivery | ImGuiDragDropFlags.AcceptNoDrawDefaultRect; + ImGuiPayloadPtr payload = ImGui.AcceptDragDropPayload(ChannelDragPayloadId, acceptFlags); if (!payload.IsNull && _dragChannelKey is { } draggedKey && !string.Equals(draggedKey, channel.Key, StringComparison.Ordinal)) { @@ -2698,7 +3861,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.EndDragDropTarget(); } - var isHoveredDuringDrag = dragActive + bool isHoveredDuringDrag = dragActive && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem | ImGuiHoveredFlags.AllowWhenOverlapped); if (!isDragTarget && isHoveredDuringDrag @@ -2712,14 +3875,14 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } } - var drawList = ImGui.GetWindowDrawList(); - var itemMin = ImGui.GetItemRectMin(); - var itemMax = ImGui.GetItemRectMax(); + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + Vector2 itemMin = ImGui.GetItemRectMin(); + Vector2 itemMax = ImGui.GetItemRectMax(); if (isHoveredDuringDrag) { - var highlight = UIColors.Get("LightlessPurple").WithAlpha(0.35f); - var highlightU32 = ImGui.ColorConvertFloat4ToU32(highlight); + Vector4 highlight = UIColors.Get("LightlessPurple").WithAlpha(0.35f); + uint highlightU32 = ImGui.ColorConvertFloat4ToU32(highlight); drawList.AddRectFilled(itemMin, itemMax, highlightU32, style.FrameRounding); drawList.AddRect(itemMin, itemMax, highlightU32, style.FrameRounding, ImDrawFlags.None, Math.Max(1f, ImGuiHelpers.GlobalScale)); } @@ -2731,23 +3894,23 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase if (isZoneChannel) { - var borderColor = UIColors.Get("LightlessOrange"); - var borderColorU32 = ImGui.ColorConvertFloat4ToU32(borderColor); - var borderThickness = Math.Max(1f, ImGuiHelpers.GlobalScale); + Vector4 borderColor = UIColors.Get("LightlessOrange"); + uint borderColorU32 = ImGui.ColorConvertFloat4ToU32(borderColor); + float borderThickness = Math.Max(1f, ImGuiHelpers.GlobalScale); drawList.AddRect(itemMin, itemMax, borderColorU32, style.FrameRounding, ImDrawFlags.None, borderThickness); } if (showBadge && badgeMetrics is { } metrics) { - var buttonSizeY = itemMax.Y - itemMin.Y; - var badgeMin = new Vector2( + float buttonSizeY = itemMax.Y - itemMin.Y; + Vector2 badgeMin = new Vector2( itemMin.X + baseFramePadding.X, itemMin.Y + (buttonSizeY - metrics.Height) * 0.5f); - var badgeMax = badgeMin + new Vector2(metrics.Width, metrics.Height); - var badgeColor = UIColors.Get("DimRed"); - var badgeColorU32 = ImGui.ColorConvertFloat4ToU32(badgeColor); + Vector2 badgeMax = badgeMin + new Vector2(metrics.Width, metrics.Height); + Vector4 badgeColor = UIColors.Get("DimRed"); + uint badgeColorU32 = ImGui.ColorConvertFloat4ToU32(badgeColor); drawList.AddRectFilled(badgeMin, badgeMax, badgeColorU32, metrics.Height * 0.5f); - var textPos = new Vector2( + Vector2 textPos = new Vector2( badgeMin.X + (metrics.Width - metrics.TextSize.X) * 0.5f, badgeMin.Y + (metrics.Height - metrics.TextSize.Y) * 0.5f); drawList.AddText(textPos, ImGui.ColorConvertFloat4ToU32(ImGuiColors.DalamudWhite), metrics.Text); @@ -2810,6 +3973,26 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.SetCursorPosY(ImGui.GetCursorPosY() - style.ItemSpacing.Y * 0.3f); } + private float GetChannelTabWidth(ChatChannelSnapshot channel, Vector2 baseFramePadding, float badgeSpacing, Vector2 badgePadding) + { + string channelLabel = GetChannelTabLabel(channel); + float textWidth = ImGui.CalcTextSize(channelLabel).X; + bool isSelected = string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal); + bool showBadge = !isSelected && channel.UnreadCount > 0; + if (!showBadge) + { + return textWidth + baseFramePadding.X * 2f; + } + + string badgeText = channel.UnreadCount > MaxBadgeDisplay + ? $"{MaxBadgeDisplay}+" + : channel.UnreadCount.ToString(CultureInfo.InvariantCulture); + Vector2 badgeTextSize = ImGui.CalcTextSize(badgeText); + float badgeWidth = badgeTextSize.X + badgePadding.X * 2f; + float customPaddingX = baseFramePadding.X + badgeWidth + badgeSpacing; + return textWidth + customPaddingX * 2f; + } + private string GetChannelTabLabel(ChatChannelSnapshot channel) { if (channel.Type != ChatChannelType.Group) @@ -2845,27 +4028,33 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private bool ShouldShowChannelTabContextMenu(ChatChannelSnapshot channel) { - if (channel.Type != ChatChannelType.Group) - { - return false; - } - - if (_chatConfigService.Current.PreferNotesForChannels.TryGetValue(channel.Key, out var preferNote) && preferNote) - { - return true; - } - - var note = GetChannelNote(channel); - return !string.IsNullOrWhiteSpace(note); + return true; } private void DrawChannelTabContextMenu(ChatChannelSnapshot channel) { + if (ImGui.MenuItem("Hide Channel")) + { + SetChannelHidden(channel.Key, true); + if (string.Equals(_selectedChannelKey, channel.Key, StringComparison.Ordinal)) + { + _selectedChannelKey = null; + _zoneChatService.SetActiveChannel(null); + } + ImGui.CloseCurrentPopup(); + return; + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Unhide channels from Chat Settings -> Visibility."); + } + var preferNote = _chatConfigService.Current.PreferNotesForChannels.TryGetValue(channel.Key, out var value) && value; var note = GetChannelNote(channel); var hasNote = !string.IsNullOrWhiteSpace(note); if (preferNote || hasNote) { + ImGui.Separator(); var label = preferNote ? "Prefer Name Instead" : "Prefer Note Instead"; if (ImGui.MenuItem(label)) { diff --git a/LightlessSync/Utils/TaskRegistry.cs b/LightlessSync/Utils/TaskRegistry.cs new file mode 100644 index 0000000..d888bbd --- /dev/null +++ b/LightlessSync/Utils/TaskRegistry.cs @@ -0,0 +1,81 @@ +using System.Collections.Concurrent; + + +namespace LightlessSync.Utils; + +public sealed class TaskRegistry where HandleType : notnull +{ + private readonly ConcurrentDictionary _activeTasks = new(); + + public Task GetOrStart(HandleType handle, Func taskFactory) + { + ActiveTask entry = _activeTasks.GetOrAdd(handle, i => new ActiveTask(() => ExecuteAndRemove(i, taskFactory))); + return entry.EnsureStarted(); + } + + public Task GetOrStart(HandleType handle, Func> taskFactory) + { + ActiveTask entry = _activeTasks.GetOrAdd(handle, i => new ActiveTask(() => ExecuteAndRemove(i, taskFactory))); + return (Task)entry.EnsureStarted(); + } + + public bool TryGetExisting(HandleType handle, out Task task) + { + if (_activeTasks.TryGetValue(handle, out ActiveTask? entry)) + { + task = entry.EnsureStarted(); + return true; + } + + task = Task.CompletedTask; + return false; + } + + private async Task ExecuteAndRemove(HandleType handle, Func taskFactory) + { + try + { + await taskFactory().ConfigureAwait(false); + } + finally + { + _activeTasks.TryRemove(handle, out _); + } + } + + private async Task ExecuteAndRemove(HandleType handle, Func> taskFactory) + { + try + { + return await taskFactory().ConfigureAwait(false); + } + finally + { + _activeTasks.TryRemove(handle, out _); + } + } + + private sealed class ActiveTask + { + private readonly object _gate = new(); + private readonly Func _starter; + private Task? _cached; + + public ActiveTask(Func starter) + { + _starter = starter; + } + + public Task EnsureStarted() + { + lock (_gate) + { + if (_cached == null || _cached.IsCompleted) + { + _cached = _starter(); + } + return _cached; + } + } + } +} diff --git a/LightlessSync/WebAPI/Files/FileDownloadDeduplicator.cs b/LightlessSync/WebAPI/Files/FileDownloadDeduplicator.cs new file mode 100644 index 0000000..42850a2 --- /dev/null +++ b/LightlessSync/WebAPI/Files/FileDownloadDeduplicator.cs @@ -0,0 +1,48 @@ +using System.Collections.Concurrent; + +namespace LightlessSync.WebAPI.Files; + +public readonly record struct DownloadClaim(bool IsOwner, Task Completion); + +public sealed class FileDownloadDeduplicator +{ + private readonly ConcurrentDictionary> _inFlight = + new(StringComparer.OrdinalIgnoreCase); + + public DownloadClaim Claim(string hash) + { + if (string.IsNullOrWhiteSpace(hash)) + { + return new DownloadClaim(false, Task.FromResult(true)); + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var existing = _inFlight.GetOrAdd(hash, tcs); + var isOwner = ReferenceEquals(existing, tcs); + return new DownloadClaim(isOwner, existing.Task); + } + + public void Complete(string hash, bool success) + { + if (string.IsNullOrWhiteSpace(hash)) + { + return; + } + + if (_inFlight.TryRemove(hash, out var tcs)) + { + tcs.TrySetResult(success); + } + } + + public void CompleteAll(bool success) + { + foreach (var entry in _inFlight.ToArray()) + { + if (_inFlight.TryRemove(entry.Key, out var tcs)) + { + tcs.TrySetResult(success); + } + } + } +} diff --git a/LightlessSync/WebAPI/Files/FileDownloadManager.cs b/LightlessSync/WebAPI/Files/FileDownloadManager.cs index 97f8af7..2b51bf5 100644 --- a/LightlessSync/WebAPI/Files/FileDownloadManager.cs +++ b/LightlessSync/WebAPI/Files/FileDownloadManager.cs @@ -1,3 +1,4 @@ +using K4os.Compression.LZ4; using K4os.Compression.LZ4.Legacy; using LightlessSync.API.Data; using LightlessSync.API.Dto.Files; @@ -8,9 +9,13 @@ using LightlessSync.PlayerData.Handlers; using LightlessSync.Services.Mediator; using LightlessSync.Services.ModelDecimation; using LightlessSync.Services.TextureCompression; +using LightlessSync.Utils; using LightlessSync.WebAPI.Files.Models; using Microsoft.Extensions.Logging; +using System.Buffers; +using System.Buffers.Binary; using System.Collections.Concurrent; +using System.IO.MemoryMappedFiles; using System.Net; using System.Net.Http.Json; @@ -18,8 +23,6 @@ namespace LightlessSync.WebAPI.Files; public partial class FileDownloadManager : DisposableMediatorSubscriberBase { - private readonly ConcurrentDictionary _downloadStatus; - private readonly FileCompactor _fileCompactor; private readonly FileCacheManager _fileDbManager; private readonly FileTransferOrchestrator _orchestrator; @@ -27,12 +30,14 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase private readonly TextureDownscaleService _textureDownscaleService; private readonly ModelDecimationService _modelDecimationService; private readonly TextureMetadataHelper _textureMetadataHelper; + private readonly FileDownloadDeduplicator _downloadDeduplicator; + private readonly ConcurrentDictionary _activeSessions = new(); + private readonly ConcurrentDictionary> _downloadQueues = new(); + private readonly TaskRegistry _downloadQueueWaiters = new(); private readonly ConcurrentDictionary _activeDownloadStreams; private readonly SemaphoreSlim _decompressGate = new(Math.Max(1, Environment.ProcessorCount / 2), Math.Max(1, Environment.ProcessorCount / 2)); - - private readonly ConcurrentQueue _deferredCompressionQueue = new(); private volatile bool _disableDirectDownloads; private int _consecutiveDirectDownloadFailures; @@ -47,9 +52,9 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase LightlessConfigService configService, TextureDownscaleService textureDownscaleService, ModelDecimationService modelDecimationService, - TextureMetadataHelper textureMetadataHelper) : base(logger, mediator) + TextureMetadataHelper textureMetadataHelper, + FileDownloadDeduplicator downloadDeduplicator) : base(logger, mediator) { - _downloadStatus = new ConcurrentDictionary(StringComparer.Ordinal); _orchestrator = orchestrator; _fileDbManager = fileCacheManager; _fileCompactor = fileCompactor; @@ -57,6 +62,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase _textureDownscaleService = textureDownscaleService; _modelDecimationService = modelDecimationService; _textureMetadataHelper = textureMetadataHelper; + _downloadDeduplicator = downloadDeduplicator; _activeDownloadStreams = new(); _lastConfigDirectDownloadsState = _configService.Current.EnableDirectDownloads; @@ -70,12 +76,46 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase foreach (var stream in _activeDownloadStreams.Keys) stream.BandwidthLimit = newLimit; }); + + Mediator.Subscribe(this, _ => + { + Logger.LogDebug("Disconnected from server, clearing in-flight downloads"); + ClearDownload(); + _downloadDeduplicator.CompleteAll(false); + }); } - public List CurrentDownloads { get; private set; } = []; + public List CurrentDownloads => _activeSessions.Values.SelectMany(s => s.Downloads).ToList(); public List ForbiddenTransfers => _orchestrator.ForbiddenTransfers; - public Guid? CurrentOwnerToken { get; private set; } - public bool IsDownloading => CurrentDownloads.Count != 0; + public bool IsDownloading => !_activeSessions.IsEmpty || _downloadQueues.Any(kvp => !kvp.Value.IsEmpty); + + public bool IsDownloadingFor(GameObjectHandler? handler) + { + if (handler is null) + return false; + + return _activeSessions.ContainsKey(handler) + || (_downloadQueues.TryGetValue(handler, out var queue) && !queue.IsEmpty); + } + + public int GetPendingDownloadCount(GameObjectHandler? handler) + { + if (handler is null) + return 0; + + var count = 0; + + if (_activeSessions.TryGetValue(handler, out var session)) + count += session.Downloads.Count; + + if (_downloadQueues.TryGetValue(handler, out var queue)) + { + foreach (var request in queue) + count += request.Session.Downloads.Count; + } + + return count; + } private bool ShouldUseDirectDownloads() => _configService.Current.EnableDirectDownloads && !_disableDirectDownloads; @@ -88,26 +128,111 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase public void ClearDownload() { - CurrentDownloads.Clear(); - _downloadStatus.Clear(); - CurrentOwnerToken = null; + foreach (var session in _activeSessions.Values.ToList()) + ClearDownload(session); + } + + private void ClearDownload(DownloadSession session) + { + foreach (var hash in session.OwnedDownloads.Keys.ToList()) + { + CompleteOwnedDownload(session, hash, false); + } + + session.Status.Clear(); + session.OwnedDownloads.Clear(); + session.Downloads.Clear(); + + if (session.Handler is not null) + _activeSessions.TryRemove(session.Handler, out _); } public async Task DownloadFiles(GameObjectHandler? gameObject, List fileReplacementDto, CancellationToken ct, bool skipDownscale = false, bool skipDecimation = false) { + var downloads = await InitiateDownloadList(gameObject, fileReplacementDto, ct).ConfigureAwait(false); + await DownloadFiles(gameObject, fileReplacementDto, downloads, ct, skipDownscale, skipDecimation).ConfigureAwait(false); + } + + public Task DownloadFiles(GameObjectHandler? gameObject, List fileReplacementDto, List downloads, CancellationToken ct, bool skipDownscale = false, bool skipDecimation = false) + { + var session = new DownloadSession(gameObject, downloads); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var request = new DownloadRequest(session, fileReplacementDto, ct, skipDownscale, skipDecimation, completion); + return EnqueueDownloadAsync(request); + } + + private Task EnqueueDownloadAsync(DownloadRequest request) + { + var handler = request.Session.Handler; + if (handler is null) + { + _ = ExecuteDownloadRequestAsync(request); + return request.Completion.Task; + } + + var queue = _downloadQueues.GetOrAdd(handler, _ => new ConcurrentQueue()); + queue.Enqueue(request); + + _downloadQueueWaiters.GetOrStart(handler, () => ProcessDownloadQueueAsync(handler)); + + return request.Completion.Task; + } + + private async Task ProcessDownloadQueueAsync(GameObjectHandler handler) + { + if (!_downloadQueues.TryGetValue(handler, out var queue)) + return; + + while (true) + { + while (queue.TryDequeue(out var request)) + { + await ExecuteDownloadRequestAsync(request).ConfigureAwait(false); + } + + await Task.Yield(); + if (queue.IsEmpty) + return; + } + } + + private async Task ExecuteDownloadRequestAsync(DownloadRequest request) + { + if (request.CancellationToken.IsCancellationRequested) + { + request.Completion.TrySetCanceled(request.CancellationToken); + return; + } + + var session = request.Session; + if (session.Handler is not null) + { + _activeSessions[session.Handler] = session; + } + Mediator.Publish(new HaltScanMessage(nameof(DownloadFiles))); try { - await DownloadFilesInternal(gameObject, fileReplacementDto, ct, skipDownscale, skipDecimation).ConfigureAwait(false); + await DownloadFilesInternal(session, request.Replacements, request.CancellationToken, request.SkipDownscale, request.SkipDecimation).ConfigureAwait(false); + request.Completion.TrySetResult(true); } - catch + catch (OperationCanceledException) when (request.CancellationToken.IsCancellationRequested) { - ClearDownload(); + ClearDownload(session); + request.Completion.TrySetCanceled(request.CancellationToken); + } + catch (Exception ex) + { + ClearDownload(session); + request.Completion.TrySetException(ex); } finally { - if (gameObject is not null) - Mediator.Publish(new DownloadFinishedMessage(gameObject)); + if (session.Handler is not null) + { + Mediator.Publish(new DownloadFinishedMessage(session.Handler)); + _activeSessions.TryRemove(session.Handler, out _); + } Mediator.Publish(new ResumeScanMessage(nameof(DownloadFiles))); } @@ -130,6 +255,30 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase base.Dispose(disposing); } + private sealed class DownloadSession + { + public DownloadSession(GameObjectHandler? handler, List downloads) + { + Handler = handler; + ObjectName = handler?.Name ?? "Unknown"; + Downloads = downloads; + } + + public GameObjectHandler? Handler { get; } + public string ObjectName { get; } + public List Downloads { get; } + public ConcurrentDictionary Status { get; } = new(StringComparer.Ordinal); + public ConcurrentDictionary OwnedDownloads { get; } = new(StringComparer.OrdinalIgnoreCase); + } + + private sealed record DownloadRequest( + DownloadSession Session, + List Replacements, + CancellationToken CancellationToken, + bool SkipDownscale, + bool SkipDecimation, + TaskCompletionSource Completion); + private sealed class DownloadSlotLease : IAsyncDisposable { private readonly FileTransferOrchestrator _orch; @@ -154,24 +303,32 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase return new DownloadSlotLease(_orchestrator); } - private void SetStatus(string key, DownloadStatus status) + private void SetStatus(DownloadSession session, string key, DownloadStatus status) { - if (_downloadStatus.TryGetValue(key, out var st)) + if (session.Status.TryGetValue(key, out var st)) st.DownloadStatus = status; } - private void AddTransferredBytes(string key, long delta) + private void AddTransferredBytes(DownloadSession session, string key, long delta) { - if (_downloadStatus.TryGetValue(key, out var st)) + if (session.Status.TryGetValue(key, out var st)) st.AddTransferredBytes(delta); } - private void MarkTransferredFiles(string key, int files) + private void MarkTransferredFiles(DownloadSession session, string key, int files) { - if (_downloadStatus.TryGetValue(key, out var st)) + if (session.Status.TryGetValue(key, out var st)) st.SetTransferredFiles(files); } + private void CompleteOwnedDownload(DownloadSession session, string hash, bool success) + { + if (session.OwnedDownloads.TryRemove(hash, out _)) + { + _downloadDeduplicator.Complete(hash, success); + } + } + private static byte MungeByte(int byteOrEof) { if (byteOrEof == -1) throw new EndOfStreamException(); @@ -218,6 +375,101 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase } } + private static async Task CopyExactlyAsync(Stream source, Stream destination, long bytesToCopy, CancellationToken ct) + { + if (bytesToCopy <= 0) + return; + + var buffer = ArrayPool.Shared.Rent(81920); + try + { + long remaining = bytesToCopy; + while (remaining > 0) + { + int read = await source.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, remaining)), ct).ConfigureAwait(false); + if (read == 0) throw new EndOfStreamException(); + await destination.WriteAsync(buffer.AsMemory(0, read), ct).ConfigureAwait(false); + remaining -= read; + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private async Task DecompressWrappedLz4ToFileAsync(string compressedPath, string outputPath, CancellationToken ct) + { + await using var input = new FileStream(compressedPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true); + byte[] header = new byte[8]; + await ReadExactlyAsync(input, header, ct).ConfigureAwait(false); + + int outputLength = BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(0, 4)); + int inputLength = BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(4, 4)); + + if (outputLength < 0 || inputLength < 0) + throw new InvalidDataException("LZ4 header contained a negative length."); + + long remainingLength = input.Length - 8; + if (inputLength > remainingLength) + throw new InvalidDataException("LZ4 header length exceeds file size."); + + var dir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + if (outputLength == 0) + { + await using var emptyStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true); + await emptyStream.FlushAsync(ct).ConfigureAwait(false); + return 0; + } + + if (inputLength >= outputLength) + { + await using var outputStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true); + await CopyExactlyAsync(input, outputStream, inputLength, ct).ConfigureAwait(false); + await outputStream.FlushAsync(ct).ConfigureAwait(false); + return outputLength; + } + + await using var mappedOutputStream = new FileStream(outputPath, FileMode.Create, FileAccess.ReadWrite, FileShare.None, 4096, FileOptions.SequentialScan); + mappedOutputStream.SetLength(outputLength); + + using var inputMap = MemoryMappedFile.CreateFromFile(compressedPath, FileMode.Open, null, 0, MemoryMappedFileAccess.Read); + using var inputView = inputMap.CreateViewAccessor(8, inputLength, MemoryMappedFileAccess.Read); + using var outputMap = MemoryMappedFile.CreateFromFile(mappedOutputStream, null, outputLength, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, leaveOpen: true); + using var outputView = outputMap.CreateViewAccessor(0, outputLength, MemoryMappedFileAccess.Write); + + unsafe + { + byte* inputPtr = null; + byte* outputPtr = null; + try + { + inputView.SafeMemoryMappedViewHandle.AcquirePointer(ref inputPtr); + outputView.SafeMemoryMappedViewHandle.AcquirePointer(ref outputPtr); + + inputPtr += inputView.PointerOffset; + outputPtr += outputView.PointerOffset; + + int decoded = LZ4Codec.Decode(inputPtr, inputLength, outputPtr, outputLength); + if (decoded != outputLength) + throw new InvalidDataException($"LZ4 decode length mismatch (expected {outputLength}, got {decoded})."); + } + finally + { + if (inputPtr != null) + inputView.SafeMemoryMappedViewHandle.ReleasePointer(); + if (outputPtr != null) + outputView.SafeMemoryMappedViewHandle.ReleasePointer(); + } + } + + outputView.Flush(); + return outputLength; + } + private static Dictionary BuildReplacementLookup(List fileReplacement) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -426,6 +678,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase } private async Task DownloadQueuedBlockFileAsync( + DownloadSession session, string statusKey, Guid requestId, List transfers, @@ -437,14 +690,14 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase requestId, transfers[0].DownloadUri, string.Join(", ", transfers.Select(c => c.Hash))); // Wait for ready WITHOUT holding a slot - SetStatus(statusKey, DownloadStatus.WaitingForQueue); + SetStatus(session, statusKey, DownloadStatus.WaitingForQueue); await WaitForDownloadReady(transfers, requestId, ct).ConfigureAwait(false); // Hold slot ONLY for the GET - SetStatus(statusKey, DownloadStatus.WaitingForSlot); + SetStatus(session, statusKey, DownloadStatus.WaitingForSlot); await using ((await AcquireSlotAsync(ct).ConfigureAwait(false)).ConfigureAwait(false)) { - SetStatus(statusKey, DownloadStatus.Downloading); + SetStatus(session, statusKey, DownloadStatus.Downloading); var requestUrl = LightlessFiles.CacheGetFullPath(transfers[0].DownloadUri, requestId); await DownloadFileThrottled(requestUrl, tempPath, progress, MungeBuffer, ct, withToken: true).ConfigureAwait(false); @@ -452,6 +705,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase } private async Task DecompressBlockFileAsync( + DownloadSession session, string downloadStatusKey, string blockFilePath, Dictionary replacementLookup, @@ -461,8 +715,8 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase bool skipDownscale, bool skipDecimation) { - SetStatus(downloadStatusKey, DownloadStatus.Decompressing); - MarkTransferredFiles(downloadStatusKey, 1); + SetStatus(session, downloadStatusKey, DownloadStatus.Decompressing); + MarkTransferredFiles(session, downloadStatusKey, 1); try { @@ -483,6 +737,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase if (!replacementLookup.TryGetValue(fileHash, out var repl)) { Logger.LogWarning("{dlName}: No replacement mapping for {fileHash}", downloadLabel, fileHash); + CompleteOwnedDownload(session, fileHash, false); // still need to skip bytes: var skip = checked((int)fileLengthBytes); fileBlockStream.Position += skip; @@ -503,49 +758,29 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase && expectedRawSize > 0 && decompressed.LongLength != expectedRawSize) { - await _fileCompactor.WriteAllBytesAsync(filePath, Array.Empty(), ct).ConfigureAwait(false); - PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation); + Logger.LogWarning("{dlName}: Decompressed size mismatch for {fileHash} (expected {expected}, got {actual})", + downloadLabel, fileHash, expectedRawSize, decompressed.LongLength); + CompleteOwnedDownload(session, fileHash, false); continue; } - MungeBuffer(compressed); - - await _decompressGate.WaitAsync(ct).ConfigureAwait(false); - try - { - // offload CPU-intensive decompression to threadpool to free up worker - await Task.Run(async () => - { - var sw = System.Diagnostics.Stopwatch.StartNew(); - - // decompress - var decompressed = LZ4Wrapper.Unwrap(compressed); - - Logger.LogTrace("{dlName}: Unwrap {fileHash} took {ms}ms (compressed {c} bytes, decompressed {d} bytes)", - downloadLabel, fileHash, sw.ElapsedMilliseconds, compressed.Length, decompressed?.Length ?? -1); - - // write to file without compacting during download - await File.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false); - PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation); - }, ct).ConfigureAwait(false); - } - finally - { - _decompressGate.Release(); - } + await _fileCompactor.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false); + PersistFileToStorage(session, fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation); } catch (EndOfStreamException) { Logger.LogWarning("{dlName}: Failure to extract file {fileHash}, stream ended prematurely", downloadLabel, fileHash); + CompleteOwnedDownload(session, fileHash, false); } catch (Exception e) { Logger.LogWarning(e, "{dlName}: Error during decompression", downloadLabel); + CompleteOwnedDownload(session, fileHash, false); } } } - SetStatus(downloadStatusKey, DownloadStatus.Completed); + SetStatus(session, downloadStatusKey, DownloadStatus.Completed); } catch (EndOfStreamException) { @@ -563,15 +798,14 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase CancellationToken ct, Guid? ownerToken = null) { - CurrentOwnerToken = ownerToken; + _ = ownerToken; var objectName = gameObjectHandler?.Name ?? "Unknown"; Logger.LogDebug("Download start: {id}", objectName); if (fileReplacement == null || fileReplacement.Count == 0) { Logger.LogDebug("{dlName}: No file replacements provided", objectName); - CurrentDownloads = []; - return CurrentDownloads; + return []; } var hashes = fileReplacement @@ -583,13 +817,32 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase if (hashes.Count == 0) { Logger.LogDebug("{dlName}: No valid hashes to download", objectName); - CurrentDownloads = []; - return CurrentDownloads; + return []; + } + + var missingHashes = new List(hashes.Count); + foreach (var hash in hashes) + { + if (_fileDbManager.GetFileCacheByHash(hash) is null) + { + missingHashes.Add(hash); + } + } + + if (missingHashes.Count == 0) + { + Logger.LogDebug("{dlName}: All requested hashes already present in cache", objectName); + return []; + } + + if (missingHashes.Count < hashes.Count) + { + Logger.LogDebug("{dlName}: Skipping {count} hashes already present in cache", objectName, hashes.Count - missingHashes.Count); } List downloadFileInfoFromService = [ - .. await FilesGetSizes(hashes, ct).ConfigureAwait(false), + .. await FilesGetSizes(missingHashes, ct).ConfigureAwait(false), ]; Logger.LogDebug("Files with size 0 or less: {files}", @@ -601,13 +854,13 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase _orchestrator.ForbiddenTransfers.Add(new DownloadFileTransfer(dto)); } - CurrentDownloads = downloadFileInfoFromService + var downloads = downloadFileInfoFromService .Distinct() .Select(d => new DownloadFileTransfer(d)) .Where(d => d.CanBeTransferred) .ToList(); - return CurrentDownloads; + return downloads; } private sealed record BatchChunk(string HostKey, string StatusKey, List Items); @@ -618,9 +871,9 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase yield return items.GetRange(i, Math.Min(chunkSize, items.Count - i)); } - private async Task DownloadFilesInternal(GameObjectHandler? gameObjectHandler, List fileReplacement, CancellationToken ct, bool skipDownscale, bool skipDecimation) + private async Task DownloadFilesInternal(DownloadSession session, List fileReplacement, CancellationToken ct, bool skipDownscale, bool skipDecimation) { - var objectName = gameObjectHandler?.Name ?? "Unknown"; + var objectName = session.ObjectName; // config toggles var configAllowsDirect = _configService.Current.EnableDirectDownloads; @@ -638,7 +891,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase var replacementLookup = BuildReplacementLookup(fileReplacement); var rawSizeLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var download in CurrentDownloads) + foreach (var download in session.Downloads) { if (string.IsNullOrWhiteSpace(download.Hash)) { @@ -654,7 +907,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase var directDownloads = new List(); var batchDownloads = new List(); - foreach (var download in CurrentDownloads) + foreach (var download in session.Downloads) { if (!string.IsNullOrEmpty(download.DirectDownloadUrl) && allowDirectDownloads) directDownloads.Add(download); @@ -662,6 +915,60 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase batchDownloads.Add(download); } + session.OwnedDownloads.Clear(); + var waitingHashes = new HashSet(StringComparer.OrdinalIgnoreCase); + var waitTasks = new List>(); + var claims = new Dictionary(StringComparer.OrdinalIgnoreCase); + + DownloadClaim GetClaim(string hash) + { + if (!claims.TryGetValue(hash, out var claim)) + { + claim = _downloadDeduplicator.Claim(hash); + claims[hash] = claim; + } + + return claim; + } + + List FilterOwned(List downloads) + { + if (downloads.Count == 0) + { + return downloads; + } + + var owned = new List(downloads.Count); + foreach (var download in downloads) + { + if (string.IsNullOrWhiteSpace(download.Hash)) + { + continue; + } + + var claim = GetClaim(download.Hash); + if (claim.IsOwner) + { + session.OwnedDownloads.TryAdd(download.Hash, 0); + owned.Add(download); + } + else if (waitingHashes.Add(download.Hash)) + { + waitTasks.Add(claim.Completion); + } + } + + return owned; + } + + directDownloads = FilterOwned(directDownloads); + batchDownloads = FilterOwned(batchDownloads); + + if (waitTasks.Count > 0) + { + Logger.LogDebug("{dlName}: {count} files already downloading elsewhere; waiting for completion.", objectName, waitTasks.Count); + } + // Chunk per host so we can fill all slots var slots = Math.Max(1, _configService.Current.ParallelDownloads); @@ -679,12 +986,12 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase .ToArray(); // init statuses - _downloadStatus.Clear(); + session.Status.Clear(); // direct downloads and batch downloads tracked separately foreach (var d in directDownloads) { - _downloadStatus[d.DirectDownloadUrl!] = new FileDownloadStatus + session.Status[d.DirectDownloadUrl!] = new FileDownloadStatus { DownloadStatus = DownloadStatus.WaitingForSlot, TotalBytes = d.Total, @@ -696,7 +1003,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase foreach (var chunk in batchChunks) { - _downloadStatus[chunk.StatusKey] = new FileDownloadStatus + session.Status[chunk.StatusKey] = new FileDownloadStatus { DownloadStatus = DownloadStatus.WaitingForQueue, TotalBytes = chunk.Items.Sum(x => x.Total), @@ -712,8 +1019,8 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase directDownloads.Count, batchDownloads.Count, batchChunks.Length); } - if (gameObjectHandler is not null) - Mediator.Publish(new DownloadStartedMessage(gameObjectHandler, _downloadStatus)); + if (session.Handler is not null) + Mediator.Publish(new DownloadStartedMessage(session.Handler, session.Status)); // work based on cpu count and slots var coreCount = Environment.ProcessorCount; @@ -724,33 +1031,53 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase var extraWorkers = (availableDecompressSlots > 0 && coreCount >= 6) ? 2 : 0; // allow some extra workers so downloads can continue while earlier items decompress. - var workerDop = Math.Clamp(baseWorkers + extraWorkers, 2, coreCount); + var workerDop = Math.Clamp(slots * 2, 2, 16); + var decompressionTasks = new ConcurrentBag(); + using var decompressionLimiter = new SemaphoreSlim(CalculateDecompressionLimit(slots)); // batch downloads Task batchTask = batchChunks.Length == 0 ? Task.CompletedTask : Parallel.ForEachAsync(batchChunks, new ParallelOptions { MaxDegreeOfParallelism = workerDop, CancellationToken = ct }, - async (chunk, token) => await ProcessBatchChunkAsync(chunk, replacementLookup, rawSizeLookup, token, skipDownscale, skipDecimation).ConfigureAwait(false)); + async (chunk, token) => await ProcessBatchChunkAsync(session, chunk, replacementLookup, rawSizeLookup, decompressionTasks, decompressionLimiter, token, skipDownscale, skipDecimation).ConfigureAwait(false)); // direct downloads Task directTask = directDownloads.Count == 0 ? Task.CompletedTask : Parallel.ForEachAsync(directDownloads, new ParallelOptions { MaxDegreeOfParallelism = workerDop, CancellationToken = ct }, - async (d, token) => await ProcessDirectAsync(d, replacementLookup, rawSizeLookup, token, skipDownscale, skipDecimation).ConfigureAwait(false)); + async (d, token) => await ProcessDirectAsync(session, d, replacementLookup, rawSizeLookup, decompressionTasks, decompressionLimiter, token, skipDownscale, skipDecimation).ConfigureAwait(false)); - await Task.WhenAll(batchTask, directTask).ConfigureAwait(false); + Task dedupWaitTask = waitTasks.Count == 0 + ? Task.FromResult(Array.Empty()) + : Task.WhenAll(waitTasks); - // process deferred compressions after all downloads complete - await ProcessDeferredCompressionsAsync(ct).ConfigureAwait(false); + try + { + await Task.WhenAll(batchTask, directTask).ConfigureAwait(false); + } + finally + { + await WaitForAllTasksAsync(decompressionTasks).ConfigureAwait(false); + } + + var dedupResults = await dedupWaitTask.ConfigureAwait(false); + + if (waitTasks.Count > 0 && dedupResults.Any(r => !r)) + { + Logger.LogWarning("{dlName}: One or more shared downloads failed; missing files may remain.", objectName); + } Logger.LogDebug("Download end: {id}", objectName); - ClearDownload(); + ClearDownload(session); } private async Task ProcessBatchChunkAsync( + DownloadSession session, BatchChunk chunk, Dictionary replacementLookup, IReadOnlyDictionary rawSizeLookup, + ConcurrentBag decompressionTasks, + SemaphoreSlim decompressionLimiter, CancellationToken ct, bool skipDownscale, bool skipDecimation) @@ -758,7 +1085,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase var statusKey = chunk.StatusKey; // enqueue (no slot) - SetStatus(statusKey, DownloadStatus.WaitingForQueue); + SetStatus(session, statusKey, DownloadStatus.WaitingForQueue); var requestIdResponse = await _orchestrator.SendRequestAsync( HttpMethod.Post, @@ -771,22 +1098,49 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase var blockFile = _fileDbManager.GetCacheFilePath(requestId.ToString("N"), "blk"); var fi = new FileInfo(blockFile); + var decompressionQueued = false; + try { - var progress = CreateInlineProgress(bytes => AddTransferredBytes(statusKey, bytes)); + // download (with slot) + var progress = CreateInlineProgress(bytes => AddTransferredBytes(session, statusKey, bytes)); // Download slot held on get - await DownloadQueuedBlockFileAsync(statusKey, requestId, chunk.Items, blockFile, progress, ct).ConfigureAwait(false); + await DownloadQueuedBlockFileAsync(session, statusKey, requestId, chunk.Items, blockFile, progress, ct).ConfigureAwait(false); // decompress if file exists if (!File.Exists(blockFile)) { Logger.LogWarning("{dlName}: Block file missing before extraction, skipping", fi.Name); - SetStatus(statusKey, DownloadStatus.Completed); + SetStatus(session, statusKey, DownloadStatus.Completed); return; } + SetStatus(session, statusKey, DownloadStatus.Decompressing); - await DecompressBlockFileAsync(statusKey, blockFile, replacementLookup, rawSizeLookup, fi.Name, ct, skipDownscale, skipDecimation).ConfigureAwait(false); + EnqueueLimitedTask( + decompressionTasks, + decompressionLimiter, + async token => + { + try + { + await DecompressBlockFileAsync(session, statusKey, blockFile, replacementLookup, rawSizeLookup, fi.Name, token, skipDownscale, skipDecimation) + .ConfigureAwait(false); + } + finally + { + try { File.Delete(blockFile); } catch {} + foreach (var item in chunk.Items) + { + if (!string.IsNullOrWhiteSpace(item.Hash)) + { + CompleteOwnedDownload(session, item.Hash, false); + } + } + } + }, + ct); + decompressionQueued = true; } catch (OperationCanceledException) { @@ -795,18 +1149,31 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase catch (Exception ex) { Logger.LogError(ex, "{dlName}: Error during batch chunk processing", fi.Name); - ClearDownload(); + ClearDownload(session); } finally { - try { File.Delete(blockFile); } catch { /* ignore */ } + if (!decompressionQueued) + { + try { File.Delete(blockFile); } catch { /* ignore */ } + foreach (var item in chunk.Items) + { + if (!string.IsNullOrWhiteSpace(item.Hash)) + { + CompleteOwnedDownload(session, item.Hash, false); + } + } + } } } private async Task ProcessDirectAsync( + DownloadSession session, DownloadFileTransfer directDownload, Dictionary replacementLookup, IReadOnlyDictionary rawSizeLookup, + ConcurrentBag decompressionTasks, + SemaphoreSlim decompressionLimiter, CancellationToken ct, bool skipDownscale, bool skipDecimation) @@ -814,25 +1181,35 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase var progress = CreateInlineProgress(bytes => { if (!string.IsNullOrEmpty(directDownload.DirectDownloadUrl)) - AddTransferredBytes(directDownload.DirectDownloadUrl!, bytes); + AddTransferredBytes(session, directDownload.DirectDownloadUrl!, bytes); }); if (!ShouldUseDirectDownloads() || string.IsNullOrEmpty(directDownload.DirectDownloadUrl)) { - await ProcessDirectAsQueuedFallbackAsync(directDownload, replacementLookup, rawSizeLookup, progress, ct, skipDownscale, skipDecimation).ConfigureAwait(false); + try + { + await ProcessDirectAsQueuedFallbackAsync(session, directDownload, replacementLookup, rawSizeLookup, progress, ct, skipDownscale, skipDecimation, decompressionTasks, decompressionLimiter).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogError(ex, "{hash}: Error during direct download fallback.", directDownload.Hash); + CompleteOwnedDownload(session, directDownload.Hash, false); + throw; + } return; } var tempFilename = _fileDbManager.GetCacheFilePath(directDownload.Hash, "bin"); + var decompressionQueued = false; try { // Download slot held on get - SetStatus(directDownload.DirectDownloadUrl!, DownloadStatus.WaitingForSlot); + SetStatus(session, directDownload.DirectDownloadUrl!, DownloadStatus.WaitingForSlot); await using ((await AcquireSlotAsync(ct).ConfigureAwait(false)).ConfigureAwait(false)) { - SetStatus(directDownload.DirectDownloadUrl!, DownloadStatus.Downloading); + SetStatus(session, directDownload.DirectDownloadUrl!, DownloadStatus.Downloading); Logger.LogDebug("Beginning direct download of {hash} from {url}", directDownload.Hash, directDownload.DirectDownloadUrl); await DownloadFileThrottled(new Uri(directDownload.DirectDownloadUrl!), tempFilename, progress, callback: null, ct, withToken: false) @@ -841,13 +1218,11 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase Interlocked.Exchange(ref _consecutiveDirectDownloadFailures, 0); - // Decompress/write - SetStatus(directDownload.DirectDownloadUrl!, DownloadStatus.Decompressing); - if (!replacementLookup.TryGetValue(directDownload.Hash, out var repl)) { Logger.LogWarning("{hash}: No replacement data found for direct download.", directDownload.Hash); - SetStatus(directDownload.DirectDownloadUrl!, DownloadStatus.Completed); + SetStatus(session, directDownload.DirectDownloadUrl!, DownloadStatus.Completed); + CompleteOwnedDownload(session, directDownload.Hash, false); return; } @@ -856,22 +1231,68 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase Logger.LogDebug("Decompressing direct download {hash} from {compressedFile} to {finalFile}", directDownload.Hash, tempFilename, finalFilename); - // Read compressed bytes and decompress in memory - byte[] compressedBytes = await File.ReadAllBytesAsync(tempFilename, ct).ConfigureAwait(false); - var decompressedBytes = LZ4Wrapper.Unwrap(compressedBytes); + SetStatus(session, directDownload.DirectDownloadUrl!, DownloadStatus.Decompressing); + EnqueueLimitedTask( + decompressionTasks, + decompressionLimiter, + async token => + { + try + { + var decompressedLength = await DecompressWrappedLz4ToFileAsync(tempFilename, finalFilename, token).ConfigureAwait(false); - if (directDownload.TotalRaw > 0 && decompressedBytes.LongLength != directDownload.TotalRaw) - { - throw new InvalidDataException( - $"{directDownload.Hash}: Decompressed size mismatch (expected {directDownload.TotalRaw}, got {decompressedBytes.LongLength})"); - } + if (directDownload.TotalRaw > 0 && decompressedLength != directDownload.TotalRaw) + { + throw new InvalidDataException( + $"{directDownload.Hash}: Decompressed size mismatch (expected {directDownload.TotalRaw}, got {decompressedLength})"); + } - await _fileCompactor.WriteAllBytesAsync(finalFilename, decompressedBytes, ct).ConfigureAwait(false); - PersistFileToStorage(directDownload.Hash, finalFilename, repl.GamePath, skipDownscale, skipDecimation); + _fileCompactor.NotifyFileWritten(finalFilename); + PersistFileToStorage(session, directDownload.Hash, finalFilename, repl.GamePath, skipDownscale, skipDecimation); - MarkTransferredFiles(directDownload.DirectDownloadUrl!, 1); - SetStatus(directDownload.DirectDownloadUrl!, DownloadStatus.Completed); - Logger.LogDebug("Finished direct download of {hash}.", directDownload.Hash); + MarkTransferredFiles(session, directDownload.DirectDownloadUrl!, 1); + SetStatus(session, directDownload.DirectDownloadUrl!, DownloadStatus.Completed); + Logger.LogDebug("Finished direct download of {hash}.", directDownload.Hash); + } + catch (Exception ex) + { + var expectedDirectDownloadFailure = ex is InvalidDataException; + var failureCount = expectedDirectDownloadFailure ? 0 : Interlocked.Increment(ref _consecutiveDirectDownloadFailures); + + if (expectedDirectDownloadFailure) + Logger.LogInformation(ex, "{hash}: Direct download unavailable, attempting queued fallback.", directDownload.Hash); + else + Logger.LogWarning(ex, "{hash}: Direct download failed, attempting queued fallback.", directDownload.Hash); + + try + { + await ProcessDirectAsQueuedFallbackAsync(session, directDownload, replacementLookup, rawSizeLookup, progress, token, skipDownscale, skipDecimation, decompressionTasks, decompressionLimiter).ConfigureAwait(false); + + if (!expectedDirectDownloadFailure && failureCount >= 3 && !_disableDirectDownloads) + { + _disableDirectDownloads = true; + Logger.LogWarning("Disabling direct downloads for this session after {count} consecutive failures.", failureCount); + } + } + catch (Exception fallbackEx) + { + Logger.LogError(fallbackEx, "{hash}: Error during direct download fallback.", directDownload.Hash); + CompleteOwnedDownload(session, directDownload.Hash, false); + SetStatus(session, directDownload.DirectDownloadUrl!, DownloadStatus.Completed); + ClearDownload(session); + } + } + finally + { + try { File.Delete(tempFilename); } + catch + { + // ignore + } + } + }, + ct); + decompressionQueued = true; } catch (OperationCanceledException ex) { @@ -880,7 +1301,8 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase else Logger.LogWarning(ex, "{hash}: Direct download cancelled unexpectedly.", directDownload.Hash); - ClearDownload(); + CompleteOwnedDownload(session, directDownload.Hash, false); + ClearDownload(session); } catch (Exception ex) { @@ -894,7 +1316,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase try { - await ProcessDirectAsQueuedFallbackAsync(directDownload, replacementLookup, rawSizeLookup, progress, ct, skipDownscale, skipDecimation).ConfigureAwait(false); + await ProcessDirectAsQueuedFallbackAsync(session, directDownload, replacementLookup, rawSizeLookup, progress, ct, skipDownscale, skipDecimation, decompressionTasks, decompressionLimiter).ConfigureAwait(false); if (!expectedDirectDownloadFailure && failureCount >= 3 && !_disableDirectDownloads) { @@ -905,34 +1327,41 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase catch (Exception fallbackEx) { Logger.LogError(fallbackEx, "{hash}: Error during direct download fallback.", directDownload.Hash); - ClearDownload(); + CompleteOwnedDownload(session, directDownload.Hash, false); + ClearDownload(session); } } finally { - try { File.Delete(tempFilename); } - catch + if (!decompressionQueued) { - // ignore + try { File.Delete(tempFilename); } + catch + { + // ignore + } } } } private async Task ProcessDirectAsQueuedFallbackAsync( + DownloadSession session, DownloadFileTransfer directDownload, Dictionary replacementLookup, IReadOnlyDictionary rawSizeLookup, IProgress progress, CancellationToken ct, bool skipDownscale, - bool skipDecimation) + bool skipDecimation, + ConcurrentBag decompressionTasks, + SemaphoreSlim decompressionLimiter) { if (string.IsNullOrEmpty(directDownload.DirectDownloadUrl)) throw new InvalidOperationException("Direct download fallback requested without a direct download URL."); var statusKey = directDownload.DirectDownloadUrl!; - SetStatus(statusKey, DownloadStatus.WaitingForQueue); + SetStatus(session, statusKey, DownloadStatus.WaitingForQueue); var requestIdResponse = await _orchestrator.SendRequestAsync( HttpMethod.Post, @@ -942,23 +1371,46 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase var requestId = Guid.Parse((await requestIdResponse.Content.ReadAsStringAsync(ct).ConfigureAwait(false)).Trim('"')); var blockFile = _fileDbManager.GetCacheFilePath(requestId.ToString("N"), "blk"); + var fi = new FileInfo(blockFile); + var decompressionQueued = false; try { - await DownloadQueuedBlockFileAsync(statusKey, requestId, [directDownload], blockFile, progress, ct).ConfigureAwait(false); + await DownloadQueuedBlockFileAsync(session, statusKey, requestId, [directDownload], blockFile, progress, ct).ConfigureAwait(false); if (!File.Exists(blockFile)) - throw new FileNotFoundException("Block file missing after direct download fallback.", blockFile); + { + Logger.LogWarning("{dlName}: Block file missing before extraction, skipping", fi.Name); + SetStatus(session, statusKey, DownloadStatus.Completed); + return; + } - await DecompressBlockFileAsync(statusKey, blockFile, replacementLookup, rawSizeLookup, $"fallback-{directDownload.Hash}", ct, skipDownscale, skipDecimation) - .ConfigureAwait(false); + SetStatus(session, statusKey, DownloadStatus.Decompressing); + EnqueueLimitedTask( + decompressionTasks, + decompressionLimiter, + async token => + { + try + { + await DecompressBlockFileAsync(session, statusKey, blockFile, replacementLookup, rawSizeLookup, $"fallback-{directDownload.Hash}", token, skipDownscale, skipDecimation) + .ConfigureAwait(false); + } + finally + { + try { File.Delete(blockFile); } catch {} + CompleteOwnedDownload(session, directDownload.Hash, false); + } + }, + ct); + decompressionQueued = true; } finally { - try { File.Delete(blockFile); } - catch + if (!decompressionQueued) { - // ignore + try { File.Delete(blockFile); } catch {} + CompleteOwnedDownload(session, directDownload.Hash, false); } } } @@ -977,9 +1429,10 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase return await response.Content.ReadFromJsonAsync>(cancellationToken: ct).ConfigureAwait(false) ?? []; } - private void PersistFileToStorage(string fileHash, string filePath, string gamePath, bool skipDownscale, bool skipDecimation) + private bool PersistFileToStorage(DownloadSession session, string fileHash, string filePath, string gamePath, bool skipDownscale, bool skipDecimation) { var fi = new FileInfo(filePath); + var persisted = false; Func RandomDayInThePast() { @@ -993,13 +1446,13 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase fi.LastAccessTime = DateTime.Today; fi.LastWriteTime = RandomDayInThePast().Invoke(); - // queue file for deferred compression instead of compressing immediately - if (_configService.Current.UseCompactor) - _deferredCompressionQueue.Enqueue(filePath); - try { var entry = _fileDbManager.CreateCacheEntryWithKnownHash(filePath, fileHash); + if (entry != null && string.Equals(entry.Hash, fileHash, StringComparison.OrdinalIgnoreCase)) + { + persisted = true; + } if (!skipDownscale && _textureDownscaleService.ShouldScheduleDownscale(filePath)) { @@ -1021,62 +1474,67 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase File.Delete(filePath); _fileDbManager.RemoveHashedFile(entry.Hash, entry.PrefixedFilePath); + persisted = false; } } catch (Exception ex) { Logger.LogWarning(ex, "Error creating cache entry"); } + finally + { + CompleteOwnedDownload(session, fileHash, persisted); + } + + return persisted; + } + + private static int CalculateDecompressionLimit(int downloadSlots) + { + var cpuBound = Math.Max(1, Math.Min(Environment.ProcessorCount, 4)); + return Math.Clamp(downloadSlots, 1, cpuBound); + } + + private static Task EnqueueLimitedTask( + ConcurrentBag tasks, + SemaphoreSlim limiter, + Func work, + CancellationToken ct) + { + var task = Task.Run(async () => + { + await limiter.WaitAsync(ct).ConfigureAwait(false); + try + { + await work(ct).ConfigureAwait(false); + } + finally + { + limiter.Release(); + } + }, ct); + + tasks.Add(task); + return task; + } + + private static async Task WaitForAllTasksAsync(ConcurrentBag tasks) + { + while (true) + { + var snapshot = tasks.ToArray(); + if (snapshot.Length == 0) + return; + + await Task.WhenAll(snapshot).ConfigureAwait(false); + + if (tasks.Count == snapshot.Length) + return; + } } private static IProgress CreateInlineProgress(Action callback) => new InlineProgress(callback); - private async Task ProcessDeferredCompressionsAsync(CancellationToken ct) - { - if (_deferredCompressionQueue.IsEmpty) - return; - - var filesToCompress = new List(); - while (_deferredCompressionQueue.TryDequeue(out var filePath)) - { - if (File.Exists(filePath)) - filesToCompress.Add(filePath); - } - - if (filesToCompress.Count == 0) - return; - - Logger.LogDebug("Starting deferred compression of {count} files", filesToCompress.Count); - - var compressionWorkers = Math.Clamp(Environment.ProcessorCount / 4, 2, 4); - - await Parallel.ForEachAsync(filesToCompress, - new ParallelOptions - { - MaxDegreeOfParallelism = compressionWorkers, - CancellationToken = ct - }, - async (filePath, token) => - { - try - { - await Task.Yield(); - if (_configService.Current.UseCompactor && File.Exists(filePath)) - { - var bytes = await File.ReadAllBytesAsync(filePath, token).ConfigureAwait(false); - await _fileCompactor.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false); - Logger.LogTrace("Compressed file: {filePath}", filePath); - } - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Failed to compress file: {filePath}", filePath); - } - }).ConfigureAwait(false); - - Logger.LogDebug("Completed deferred compression of {count} files", filesToCompress.Count); - } - private sealed class InlineProgress : IProgress { private readonly Action _callback; diff --git a/LightlessSync/packages.lock.json b/LightlessSync/packages.lock.json index 45d7722..5de5367 100644 --- a/LightlessSync/packages.lock.json +++ b/LightlessSync/packages.lock.json @@ -617,6 +617,12 @@ "resolved": "10.0.1", "contentHash": "xfaHEHVDkMOOZR5S6ZGezD0+vekdH1Nx/9Ih8/rOqOGSOk1fxiN3u94bYkBW/wigj0Uw2Wt3vvRj9mtYdgwEjw==" }, + "lightlesscompactor": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "[10.0.1, )" + } + }, "lightlesssync.api": { "type": "Project", "dependencies": { -- 2.49.1 From e2d663cae96a2f0eb3478ca8f1b08b9c67516344 Mon Sep 17 00:00:00 2001 From: azyges Date: Fri, 16 Jan 2026 18:19:12 +0900 Subject: [PATCH 68/87] removal of *temporary* collections --- .../Ipc/Penumbra/PenumbraCollections.cs | 79 +-- .../Interop/Ipc/Penumbra/PenumbraResource.cs | 23 +- .../Configurations/LightlessConfig.cs | 1 + .../Models/OrphanableTempCollectionEntry.cs | 7 + .../PlayerData/Factories/PlayerDataFactory.cs | 23 +- .../PlayerData/Pairs/IPairHandlerAdapter.cs | 2 - LightlessSync/PlayerData/Pairs/Pair.cs | 9 - .../PlayerData/Pairs/PairDebugInfo.cs | 6 - .../PlayerData/Pairs/PairHandlerAdapter.cs | 662 +++++------------- .../Services/PenumbraTempCollectionJanitor.cs | 136 +++- LightlessSync/UI/SettingsUi.cs | 4 - 11 files changed, 352 insertions(+), 600 deletions(-) create mode 100644 LightlessSync/LightlessConfiguration/Models/OrphanableTempCollectionEntry.cs diff --git a/LightlessSync/Interop/Ipc/Penumbra/PenumbraCollections.cs b/LightlessSync/Interop/Ipc/Penumbra/PenumbraCollections.cs index c095471..c3e497a 100644 --- a/LightlessSync/Interop/Ipc/Penumbra/PenumbraCollections.cs +++ b/LightlessSync/Interop/Ipc/Penumbra/PenumbraCollections.cs @@ -1,10 +1,8 @@ -using System.Collections.Concurrent; using Dalamud.Plugin; using LightlessSync.Interop.Ipc.Framework; using LightlessSync.Services; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; -using Penumbra.Api.Enums; using Penumbra.Api.IpcSubscribers; namespace LightlessSync.Interop.Ipc.Penumbra; @@ -16,10 +14,6 @@ public sealed class PenumbraCollections : PenumbraBase private readonly DeleteTemporaryCollection _removeTemporaryCollection; private readonly AddTemporaryMod _addTemporaryMod; private readonly RemoveTemporaryMod _removeTemporaryMod; - private readonly GetCollections _getCollections; - private readonly ConcurrentDictionary _activeTemporaryCollections = new(); - - private int _cleanupScheduled; public PenumbraCollections( ILogger logger, @@ -32,7 +26,6 @@ public sealed class PenumbraCollections : PenumbraBase _removeTemporaryCollection = new DeleteTemporaryCollection(pluginInterface); _addTemporaryMod = new AddTemporaryMod(pluginInterface); _removeTemporaryMod = new RemoveTemporaryMod(pluginInterface); - _getCollections = new GetCollections(pluginInterface); } public override string Name => "Penumbra.Collections"; @@ -62,16 +55,11 @@ public sealed class PenumbraCollections : PenumbraBase var (collectionId, collectionName) = await DalamudUtil.RunOnFrameworkThread(() => { var name = $"Lightless_{uid}"; - _createNamedTemporaryCollection.Invoke(name, name, out var tempCollectionId); - logger.LogTrace("Creating Temp Collection {CollectionName}, GUID: {CollectionId}", name, tempCollectionId); + var createResult = _createNamedTemporaryCollection.Invoke(name, name, out var tempCollectionId); + logger.LogTrace("Creating Temp Collection {CollectionName}, GUID: {CollectionId}, Result: {Result}", name, tempCollectionId, createResult); return (tempCollectionId, name); }).ConfigureAwait(false); - if (collectionId != Guid.Empty) - { - _activeTemporaryCollections[collectionId] = collectionName; - } - return collectionId; } @@ -89,7 +77,6 @@ public sealed class PenumbraCollections : PenumbraBase logger.LogTrace("[{ApplicationId}] RemoveTemporaryCollection: {Result}", applicationId, result); }).ConfigureAwait(false); - _activeTemporaryCollections.TryRemove(collectionId, out _); } public async Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary modPaths) @@ -131,67 +118,5 @@ public sealed class PenumbraCollections : PenumbraBase protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current) { - if (current == IpcConnectionState.Available) - { - ScheduleCleanup(); - } - else if (previous == IpcConnectionState.Available && current != IpcConnectionState.Available) - { - Interlocked.Exchange(ref _cleanupScheduled, 0); - } } - - private void ScheduleCleanup() - { - if (Interlocked.Exchange(ref _cleanupScheduled, 1) != 0) - { - return; - } - - _ = Task.Run(CleanupTemporaryCollectionsAsync); - } - - private async Task CleanupTemporaryCollectionsAsync() - { - if (!IsAvailable) - { - return; - } - - try - { - var collections = await DalamudUtil.RunOnFrameworkThread(() => _getCollections.Invoke()).ConfigureAwait(false); - foreach (var (collectionId, name) in collections) - { - if (!IsLightlessCollectionName(name) || _activeTemporaryCollections.ContainsKey(collectionId)) - { - continue; - } - - Logger.LogDebug("Cleaning up stale temporary collection {CollectionName} ({CollectionId})", name, collectionId); - var deleteResult = await DalamudUtil.RunOnFrameworkThread(() => - { - var result = (PenumbraApiEc)_removeTemporaryCollection.Invoke(collectionId); - Logger.LogTrace("Cleanup RemoveTemporaryCollection result for {CollectionName} ({CollectionId}): {Result}", name, collectionId, result); - return result; - }).ConfigureAwait(false); - - if (deleteResult == PenumbraApiEc.Success) - { - _activeTemporaryCollections.TryRemove(collectionId, out _); - } - else - { - Logger.LogDebug("Skipped removing temporary collection {CollectionName} ({CollectionId}). Result: {Result}", name, collectionId, deleteResult); - } - } - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Failed to clean up Penumbra temporary collections"); - } - } - - private static bool IsLightlessCollectionName(string? name) - => !string.IsNullOrEmpty(name) && name.StartsWith("Lightless_", StringComparison.Ordinal); } diff --git a/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs b/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs index 19a1e7f..9ca2df0 100644 --- a/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs +++ b/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs @@ -4,6 +4,7 @@ using LightlessSync.PlayerData.Handlers; using LightlessSync.Services; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; +using System.Diagnostics; using System.Globalization; using Penumbra.Api.Helpers; using Penumbra.Api.IpcSubscribers; @@ -42,17 +43,33 @@ public sealed class PenumbraResource : PenumbraBase return null; } - return await DalamudUtil.RunOnFrameworkThread(() => + var requestId = Guid.NewGuid(); + var totalTimer = Stopwatch.StartNew(); + logger.LogTrace("[{requestId}] Requesting Penumbra.GetGameObjectResourcePaths for {handler}", requestId, handler); + + var result = await DalamudUtil.RunOnFrameworkThread(() => { - logger.LogTrace("Calling On IPC: Penumbra.GetGameObjectResourcePaths"); var idx = handler.GetGameObject()?.ObjectIndex; if (idx == null) { + logger.LogTrace("[{requestId}] GetGameObjectResourcePaths aborted (missing object index) for {handler}", requestId, handler); return null; } - return _gameObjectResourcePaths.Invoke(idx.Value)[0]; + logger.LogTrace("[{requestId}] Invoking Penumbra.GetGameObjectResourcePaths for index {index}", requestId, idx.Value); + var invokeTimer = Stopwatch.StartNew(); + var data = _gameObjectResourcePaths.Invoke(idx.Value)[0]; + invokeTimer.Stop(); + logger.LogTrace("[{requestId}] Penumbra.GetGameObjectResourcePaths returned {count} entries in {elapsedMs}ms", + requestId, data?.Count ?? 0, invokeTimer.ElapsedMilliseconds); + return data; }).ConfigureAwait(false); + + totalTimer.Stop(); + logger.LogTrace("[{requestId}] Penumbra.GetGameObjectResourcePaths finished in {elapsedMs}ms (null: {isNull})", + requestId, totalTimer.ElapsedMilliseconds, result is null); + + return result; } public string GetMetaManipulations() diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index 35c1958..0663258 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -161,6 +161,7 @@ public class LightlessConfig : ILightlessConfiguration public string LastSeenVersion { get; set; } = string.Empty; public bool EnableParticleEffects { get; set; } = true; public HashSet OrphanableTempCollections { get; set; } = []; + public List OrphanableTempCollectionEntries { get; set; } = []; public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Safe; public bool AnimationAllowOneBasedShift { get; set; } = true; diff --git a/LightlessSync/LightlessConfiguration/Models/OrphanableTempCollectionEntry.cs b/LightlessSync/LightlessConfiguration/Models/OrphanableTempCollectionEntry.cs new file mode 100644 index 0000000..2288018 --- /dev/null +++ b/LightlessSync/LightlessConfiguration/Models/OrphanableTempCollectionEntry.cs @@ -0,0 +1,7 @@ +namespace LightlessSync.LightlessConfiguration.Models; + +public sealed class OrphanableTempCollectionEntry +{ + public Guid Id { get; set; } + public DateTime RegisteredAtUtc { get; set; } = DateTime.MinValue; +} diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index 744e503..51dba8f 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -257,7 +257,28 @@ public class PlayerDataFactory 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"); + Guid penumbraRequestId = Guid.Empty; + Stopwatch? penumbraSw = null; + if (logDebug) + { + penumbraRequestId = Guid.NewGuid(); + penumbraSw = Stopwatch.StartNew(); + _logger.LogDebug("Penumbra GetCharacterData start {id} for {obj}", penumbraRequestId, playerRelatedObject); + } + + var resolvedPaths = await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false); + + if (logDebug) + { + penumbraSw!.Stop(); + _logger.LogDebug("Penumbra GetCharacterData done {id} in {elapsedMs}ms (count={count})", + penumbraRequestId, + penumbraSw.ElapsedMilliseconds, + resolvedPaths?.Count ?? -1); + } + + if (resolvedPaths == null) + throw new InvalidOperationException("Penumbra returned null data; couldn't proceed with character"); ct.ThrowIfCancellationRequested(); var staticBuildTask = Task.Run(() => BuildStaticReplacements(resolvedPaths), ct); diff --git a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs index d04cc3b..8375ed3 100644 --- a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs @@ -30,8 +30,6 @@ public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject int MissingCriticalMods { get; } int MissingNonCriticalMods { get; } int MissingForbiddenMods { get; } - DateTime? InvisibleSinceUtc { get; } - DateTime? VisibilityEvictionDueAtUtc { get; } void Initialize(); void ApplyData(CharacterData data); diff --git a/LightlessSync/PlayerData/Pairs/Pair.cs b/LightlessSync/PlayerData/Pairs/Pair.cs index e95b7fe..9d794f8 100644 --- a/LightlessSync/PlayerData/Pairs/Pair.cs +++ b/LightlessSync/PlayerData/Pairs/Pair.cs @@ -217,12 +217,6 @@ public class Pair if (handler is null) return PairDebugInfo.Empty; - var now = DateTime.UtcNow; - var dueAt = handler.VisibilityEvictionDueAtUtc; - var remainingSeconds = dueAt.HasValue - ? Math.Max(0, (dueAt.Value - now).TotalSeconds) - : (double?)null; - return new PairDebugInfo( true, handler.Initialized, @@ -231,9 +225,6 @@ public class Pair handler.LastDataReceivedAt, handler.LastApplyAttemptAt, handler.LastSuccessfulApplyAt, - handler.InvisibleSinceUtc, - handler.VisibilityEvictionDueAtUtc, - remainingSeconds, handler.LastFailureReason, handler.LastBlockingConditions, handler.IsApplying, diff --git a/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs b/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs index 60abf35..820c687 100644 --- a/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs +++ b/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs @@ -8,9 +8,6 @@ public sealed record PairDebugInfo( DateTime? LastDataReceivedAt, DateTime? LastApplyAttemptAt, DateTime? LastSuccessfulApplyAt, - DateTime? InvisibleSinceUtc, - DateTime? VisibilityEvictionDueAtUtc, - double? VisibilityEvictionRemainingSeconds, string? LastFailureReason, IReadOnlyList BlockingConditions, bool IsApplying, @@ -32,9 +29,6 @@ public sealed record PairDebugInfo( null, null, null, - null, - null, - null, Array.Empty(), false, false, diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index c4f3e70..9a3683f 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -24,7 +24,6 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind; -using FileReplacementDataComparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer; namespace LightlessSync.PlayerData.Pairs; @@ -66,32 +65,26 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private CombatData? _dataReceivedInDowntime; private CancellationTokenSource? _downloadCancellationTokenSource; private bool _forceApplyMods = false; - private bool _forceFullReapply; - private Dictionary<(string GamePath, string? Hash), string>? _lastAppliedModdedPaths; - private bool _needsCollectionRebuild; private bool _pendingModReapply; private bool _lastModApplyDeferred; private int _lastMissingCriticalMods; private int _lastMissingNonCriticalMods; private int _lastMissingForbiddenMods; - private bool _lastMissingCachedFiles; - private string? _lastSuccessfulDataHash; private bool _isVisible; private Guid _penumbraCollection; private readonly object _collectionGate = new(); + private Task? _penumbraCollectionTask; private bool _redrawOnNextApplication = false; private readonly object _initializationGate = new(); private readonly object _pauseLock = new(); private Task _pauseTransitionTask = Task.CompletedTask; private bool _pauseRequested; + private bool _wasRevertedOnPause; private DateTime? _lastDataReceivedAt; private DateTime? _lastApplyAttemptAt; private DateTime? _lastSuccessfulApplyAt; private string? _lastFailureReason; private IReadOnlyList _lastBlockingConditions = Array.Empty(); - private readonly object _visibilityGraceGate = new(); - private CancellationTokenSource? _visibilityGraceCts; - private static readonly TimeSpan VisibilityEvictionGrace = TimeSpan.FromMinutes(1); private readonly object _ownedRetryGate = new(); private readonly Dictionary> _pendingOwnedChanges = new(); private CancellationTokenSource? _ownedRetryCts; @@ -117,8 +110,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private bool _lastAllowNeighborTolerance; private readonly ConcurrentDictionary _dumpedRemoteSkeletonForHash = new(StringComparer.OrdinalIgnoreCase); - private DateTime? _invisibleSinceUtc; - private DateTime? _visibilityEvictionDueAtUtc; private DateTime _nextActorLookupUtc = DateTime.MinValue; private static readonly TimeSpan ActorLookupInterval = TimeSpan.FromSeconds(1); private static readonly SemaphoreSlim ActorInitializationLimiter = new(1, 1); @@ -132,8 +123,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private ushort _lastKnownObjectIndex = ushort.MaxValue; private string? _lastKnownName; - public DateTime? InvisibleSinceUtc => _invisibleSinceUtc; - public DateTime? VisibilityEvictionDueAtUtc => _visibilityEvictionDueAtUtc; public string Ident { get; } public bool Initialized { get; private set; } public bool ScheduledForDeletion { get; set; } @@ -150,23 +139,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (!_isVisible) { DisableSync(); - - _invisibleSinceUtc = DateTime.UtcNow; - _visibilityEvictionDueAtUtc = _invisibleSinceUtc.Value.Add(VisibilityEvictionGrace); - - StartVisibilityGraceTask(); - } - else - { - CancelVisibilityGraceTask(); - - _invisibleSinceUtc = null; - _visibilityEvictionDueAtUtc = null; - - ScheduledForDeletion = false; - - if (_charaHandler is not null && _charaHandler.Address != nint.Zero) - _ = EnsurePenumbraCollection(); } var user = GetPrimaryUserData(); @@ -246,6 +218,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _tempCollectionJanitor = tempCollectionJanitor; _modelAnalyzer = modelAnalyzer; _configService = configService; + + _ = EnsurePenumbraCollectionAsync(); } public void Initialize() @@ -286,9 +260,9 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _charaHandler?.Invalidate(); IsVisible = false; }); - Mediator.Subscribe(this, _ => + Mediator.Subscribe(this, __ => { - ResetPenumbraCollection(releaseFromPenumbra: false, reason: "PenumbraInitialized"); + _ = EnsurePenumbraCollectionAsync(); if (!IsVisible && _charaHandler is not null) { PlayerName = string.Empty; @@ -297,7 +271,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } EnableSync(); }); - Mediator.Subscribe(this, _ => ResetPenumbraCollection(releaseFromPenumbra: false, reason: "PenumbraDisposed")); Mediator.Subscribe(this, msg => { if (msg.GameObjectHandler == _charaHandler) @@ -324,23 +297,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return; } - if (_pendingModReapply && IsVisible) - { - if (LastReceivedCharacterData is not null) - { - Logger.LogDebug("Downloads finished for {handler}, reapplying pending mod data", GetLogIdentifier()); - ApplyLastReceivedData(forced: true); - return; - } - - if (_cachedData is not null) - { - Logger.LogDebug("Downloads finished for {handler}, reapplying pending mod data from cache", GetLogIdentifier()); - ApplyCharacterData(Guid.NewGuid(), _cachedData, forceApplyCustomization: true); - return; - } - } - TryApplyQueuedData(); }); @@ -429,32 +385,29 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return string.Equals(alias, Ident, StringComparison.Ordinal) ? alias : $"{alias} ({Ident})"; } - private Guid EnsurePenumbraCollection() + private Task EnsurePenumbraCollectionAsync() { - if (!IsVisible) - { - return Guid.Empty; - } - if (_penumbraCollection != Guid.Empty) { - return _penumbraCollection; + return Task.FromResult(_penumbraCollection); } lock (_collectionGate) { if (_penumbraCollection != Guid.Empty) { - return _penumbraCollection; + return Task.FromResult(_penumbraCollection); } - var cached = _pairStateCache.TryGetTemporaryCollection(Ident); - if (cached.HasValue && cached.Value != Guid.Empty) - { - _penumbraCollection = cached.Value; - return _penumbraCollection; - } + _penumbraCollectionTask ??= Task.Run(CreatePenumbraCollectionAsync); + return _penumbraCollectionTask; + } + } + private async Task CreatePenumbraCollectionAsync() + { + try + { if (!_ipcManager.Penumbra.APIAvailable) { return Guid.Empty; @@ -462,16 +415,28 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa var user = GetPrimaryUserDataSafe(); var uid = !string.IsNullOrEmpty(user.UID) ? user.UID : Ident; - var created = _ipcManager.Penumbra.CreateTemporaryCollectionAsync(Logger, uid) - .ConfigureAwait(false).GetAwaiter().GetResult(); - if (created != Guid.Empty) + var collection = await _ipcManager.Penumbra.CreateTemporaryCollectionAsync(Logger, uid).ConfigureAwait(false); + if (collection != Guid.Empty) { - _penumbraCollection = created; - _pairStateCache.StoreTemporaryCollection(Ident, created); - _tempCollectionJanitor.Register(created); + _tempCollectionJanitor.Register(collection); } - return _penumbraCollection; + lock (_collectionGate) + { + if (_penumbraCollection == Guid.Empty && collection != Guid.Empty) + { + _penumbraCollection = collection; + } + } + + return collection; + } + finally + { + lock (_collectionGate) + { + _penumbraCollectionTask = null; + } } } @@ -489,18 +454,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } } - var cached = _pairStateCache.ClearTemporaryCollection(Ident); - if (cached.HasValue && cached.Value != Guid.Empty) - { - toRelease = cached.Value; - hadCollection = true; - } - if (hadCollection) { - _needsCollectionRebuild = true; - _forceFullReapply = true; - _forceApplyMods = true; _tempCollectionJanitor.Unregister(toRelease); } @@ -639,43 +594,18 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return; } - var hasMissingCachedFiles = HasMissingCachedFiles(LastReceivedCharacterData); - var missingStarted = !_lastMissingCachedFiles && hasMissingCachedFiles; - var missingResolved = _lastMissingCachedFiles && !hasMissingCachedFiles; - _lastMissingCachedFiles = hasMissingCachedFiles; - var shouldForce = forced || missingStarted || missingResolved; - var forceApplyCustomization = forced; - if (IsPaused()) { Logger.LogTrace("Permissions paused for {Ident}, skipping reapply", Ident); return; } - var sanitized = CloneAndSanitizeLastReceived(out var dataHash); + var sanitized = CloneAndSanitizeLastReceived(out _); if (sanitized is null) { Logger.LogTrace("Sanitized data null for {Ident}", Ident); return; } - var dataApplied = !string.IsNullOrEmpty(dataHash) - && string.Equals(dataHash, _lastSuccessfulDataHash ?? string.Empty, StringComparison.Ordinal); - var needsApply = !dataApplied; - var modFilesChanged = PlayerModFilesChanged(sanitized, _cachedData); - var shouldForceMods = shouldForce || modFilesChanged; - forceApplyCustomization = forced || needsApply; - var suppressForcedModRedraw = !forced && hasMissingCachedFiles && dataApplied; - - if (shouldForceMods) - { - _forceApplyMods = true; - _forceFullReapply = true; - LastAppliedDataBytes = -1; - LastAppliedDataTris = -1; - LastAppliedApproximateEffectiveTris = -1; - LastAppliedApproximateVRAMBytes = -1; - LastAppliedApproximateEffectiveVRAMBytes = -1; - } _pairStateCache.Store(Ident, sanitized); @@ -696,11 +626,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { Logger.LogTrace("Handler for {Ident} not visible, caching sanitized data for later", Ident); _cachedData = sanitized; - _forceFullReapply = true; return; } - ApplyCharacterData(Guid.NewGuid(), sanitized, forceApplyCustomization, suppressForcedModRedraw); + ApplyCharacterData(Guid.NewGuid(), sanitized, forceApplyCustomization: forced); } public bool FetchPerformanceMetricsFromCache() @@ -867,54 +796,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa new PairPerformanceMetrics(LastAppliedDataTris, LastAppliedApproximateVRAMBytes, LastAppliedApproximateEffectiveVRAMBytes, LastAppliedApproximateEffectiveTris)); } - private bool HasMissingCachedFiles(CharacterData characterData) - { - try - { - HashSet inspectedHashes = new(StringComparer.OrdinalIgnoreCase); - foreach (var replacements in characterData.FileReplacements.Values) - { - foreach (var replacement in replacements) - { - if (!string.IsNullOrEmpty(replacement.FileSwapPath)) - { - if (Path.IsPathRooted(replacement.FileSwapPath) && !File.Exists(replacement.FileSwapPath)) - { - Logger.LogTrace("Missing file swap path {Path} detected for {Handler}", replacement.FileSwapPath, GetLogIdentifier()); - return true; - } - continue; - } - - if (string.IsNullOrEmpty(replacement.Hash) || !inspectedHashes.Add(replacement.Hash)) - { - continue; - } - - var cacheEntry = _fileDbManager.GetFileCacheByHash(replacement.Hash); - if (cacheEntry is null) - { - Logger.LogTrace("Missing cached file {Hash} detected for {Handler}", replacement.Hash, GetLogIdentifier()); - return true; - } - - if (!File.Exists(cacheEntry.ResolvedFilepath)) - { - Logger.LogTrace("Cached file {Hash} missing on disk for {Handler}, removing cache entry", replacement.Hash, GetLogIdentifier()); - _fileDbManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath); - return true; - } - } - } - } - catch (Exception ex) - { - Logger.LogDebug(ex, "Failed to determine cache availability for {Handler}", GetLogIdentifier()); - } - - return false; - } - private CharacterData? RemoveNotSyncedFiles(CharacterData? data) { Logger.LogTrace("Removing not synced files for {Ident}", Ident); @@ -967,25 +848,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return data; } - private bool HasValidCachedModdedPaths() - { - if (_lastAppliedModdedPaths is null || _lastAppliedModdedPaths.Count == 0) - { - return false; - } - - foreach (var entry in _lastAppliedModdedPaths) - { - if (string.IsNullOrEmpty(entry.Value) || !File.Exists(entry.Value)) - { - Logger.LogDebug("Cached file path {path} missing for {handler}, forcing recalculation", entry.Value ?? "empty", GetLogIdentifier()); - return false; - } - } - - return true; - } - private bool IsForbiddenHash(string hash) => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, hash, StringComparison.Ordinal)); @@ -1155,31 +1017,23 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa .Any(p => p.Value.Contains(PlayerChanges.ModManip) || p.Value.Contains(PlayerChanges.ModFiles)); _forceApplyMods = hasDiffMods || _forceApplyMods || _cachedData == null; _cachedData = characterData; - _forceFullReapply = true; Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods); + return; } - var forceModsForMissing = _pendingModReapply; - if (!forceModsForMissing && HasMissingCachedFiles(characterData)) - { - forceModsForMissing = true; - } - - if (forceModsForMissing) - { - _forceApplyMods = true; - } - - var suppressForcedModRedrawOnForcedApply = suppressForcedModRedraw || forceModsForMissing; - SetUploading(false); + _pendingModReapply = false; + _lastModApplyDeferred = false; + _lastMissingCriticalMods = 0; + _lastMissingNonCriticalMods = 0; + _lastMissingForbiddenMods = 0; + Logger.LogDebug("[BASE-{appbase}] Applying data for {player}, forceApplyCustomization: {forced}, forceApplyMods: {forceMods}", applicationBase, GetLogIdentifier(), forceApplyCustomization, _forceApplyMods); Logger.LogDebug("[BASE-{appbase}] Hash for data is {newHash}, current cache hash is {oldHash}", applicationBase, characterData.DataHash.Value, _cachedData?.DataHash.Value ?? "NODATA"); - if (handlerReady - && string.Equals(characterData.DataHash.Value, _cachedData?.DataHash.Value ?? string.Empty, StringComparison.Ordinal) - && !forceApplyCustomization && !_forceApplyMods) + if (string.Equals(characterData.DataHash.Value, _cachedData?.DataHash.Value ?? string.Empty, StringComparison.Ordinal) + && !forceApplyCustomization) { return; } @@ -1187,8 +1041,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational, "Applying Character Data"))); + _forceApplyMods |= forceApplyCustomization; + var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this, - forceApplyCustomization, _forceApplyMods, suppressForcedModRedrawOnForcedApply); + forceApplyCustomization, _forceApplyMods, suppressForcedModRedraw); if (handlerReady && _forceApplyMods) { @@ -1207,11 +1063,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } Logger.LogDebug("[BASE-{appbase}] Downloading and applying character for {name}", applicationBase, GetPrimaryAliasOrUidSafe()); - - var forceFullReapply = _forceFullReapply - || LastAppliedApproximateVRAMBytes < 0 || LastAppliedDataTris < 0 || LastAppliedApproximateEffectiveTris < 0; - - DownloadAndApplyCharacter(applicationBase, characterData.DeepClone(), charaDataToUpdate, forceFullReapply); + DownloadAndApplyCharacter(applicationBase, characterData.DeepClone(), charaDataToUpdate); } public override string ToString() @@ -1245,46 +1097,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } } - private void CancelVisibilityGraceTask() - { - lock (_visibilityGraceGate) - { - _visibilityGraceCts?.CancelDispose(); - _visibilityGraceCts = null; - } - } - - private void StartVisibilityGraceTask() - { - CancellationToken token; - lock (_visibilityGraceGate) - { - _visibilityGraceCts = _visibilityGraceCts?.CancelRecreate() ?? new CancellationTokenSource(); - token = _visibilityGraceCts.Token; - } - - _visibilityGraceTask = Task.Run(async () => - { - try - { - await Task.Delay(VisibilityEvictionGrace, token).ConfigureAwait(false); - token.ThrowIfCancellationRequested(); - if (IsVisible) return; - - ScheduledForDeletion = true; - ResetPenumbraCollection(reason: "VisibilityLostTimeout"); - } - catch (OperationCanceledException) - { - // operation cancelled, do nothing - } - catch (Exception ex) - { - Logger.LogDebug(ex, "Visibility grace task failed for {handler}", GetLogIdentifier()); - } - }, CancellationToken.None); - } - private void ScheduleOwnedObjectRetry(ObjectKind kind, HashSet changes) { if (kind == ObjectKind.Player || changes.Count == 0) @@ -1477,10 +1289,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _ownedRetryCts = null; _downloadManager.Dispose(); _charaHandler?.Dispose(); - CancelVisibilityGraceTask(); _charaHandler = null; - _invisibleSinceUtc = null; - _visibilityEvictionDueAtUtc = null; if (!string.IsNullOrEmpty(name)) { @@ -1496,7 +1305,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa var isStopping = _lifetime.ApplicationStopping.IsCancellationRequested; if (isStopping) { - ResetPenumbraCollection(reason: "DisposeStopping", awaitIpc: false); ScheduleSafeRevertOnDisposal(applicationId, name, alias); return; } @@ -1555,9 +1363,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { PlayerName = null; _cachedData = null; - _lastSuccessfulDataHash = null; - _lastAppliedModdedPaths = null; - _needsCollectionRebuild = false; _performanceMetricsCache.Clear(Ident); Logger.LogDebug("Disposing {name} complete", name); } @@ -1878,87 +1683,27 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return result; } - private static bool PlayerModFilesChanged(CharacterData newData, CharacterData? previousData) - { - return !FileReplacementListsEqual( - TryGetFileReplacementList(newData, ObjectKind.Player), - TryGetFileReplacementList(previousData, ObjectKind.Player)); - } - - private static IReadOnlyCollection? TryGetFileReplacementList(CharacterData? data, ObjectKind objectKind) - { - if (data is null) - { - return null; - } - - return data.FileReplacements.TryGetValue(objectKind, out var list) ? list : null; - } - - private static bool FileReplacementListsEqual(IReadOnlyCollection? left, IReadOnlyCollection? right) - { - if (left is null || left.Count == 0) - { - return right is null || right.Count == 0; - } - - if (right is null || right.Count == 0) - { - return false; - } - - var comparer = FileReplacementDataComparer.Instance; - return !left.Except(right, comparer).Any() && !right.Except(left, comparer).Any(); - } - - private void DownloadAndApplyCharacter(Guid applicationBase, CharacterData charaData, Dictionary> updatedData, bool forceFullReapply) + private void DownloadAndApplyCharacter(Guid applicationBase, CharacterData charaData, Dictionary> updatedData) { if (!updatedData.Any()) { - if (forceFullReapply) - { - updatedData = BuildFullChangeSet(charaData); - } - - if (!updatedData.Any()) - { - Logger.LogDebug("[BASE-{appBase}] Nothing to update for {obj}", applicationBase, GetLogIdentifier()); - _forceFullReapply = false; - return; - } + Logger.LogDebug("[BASE-{appBase}] Nothing to update for {obj}", applicationBase, GetLogIdentifier()); + return; } var updateModdedPaths = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModFiles)); var updateManip = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModManip)); - var needsCollectionRebuild = _needsCollectionRebuild; - var reuseCachedModdedPaths = !updateModdedPaths && needsCollectionRebuild && _lastAppliedModdedPaths is not null; - updateModdedPaths = updateModdedPaths || needsCollectionRebuild; - updateManip = updateManip || needsCollectionRebuild; - Dictionary<(string GamePath, string? Hash), string>? cachedModdedPaths = null; - if (reuseCachedModdedPaths) - { - if (HasValidCachedModdedPaths()) - { - cachedModdedPaths = _lastAppliedModdedPaths; - } - else - { - Logger.LogDebug("{handler}: Cached files missing, recalculating mappings", GetLogIdentifier()); - _lastAppliedModdedPaths = null; - } - } _downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource(); var downloadToken = _downloadCancellationTokenSource.Token; - _ = DownloadAndApplyCharacterAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, cachedModdedPaths, downloadToken) + _ = DownloadAndApplyCharacterAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, downloadToken) .ConfigureAwait(false); } private Task? _pairDownloadTask; - private Task _visibilityGraceTask; private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary> updatedData, - bool updateModdedPaths, bool updateManip, Dictionary<(string GamePath, string? Hash), string>? cachedModdedPaths, CancellationToken downloadToken) + bool updateModdedPaths, bool updateManip, CancellationToken downloadToken) { var concurrencyLease = await _pairProcessingLimiter.AcquireAsync(downloadToken).ConfigureAwait(false); try @@ -1966,154 +1711,96 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa bool skipDownscaleForPair = ShouldSkipDownscale(); bool skipDecimationForPair = ShouldSkipDecimation(); var user = GetPrimaryUserData(); - Dictionary<(string GamePath, string? Hash), string> moddedPaths; - List missingReplacements = []; + Dictionary<(string GamePath, string? Hash), string> moddedPaths = []; if (updateModdedPaths) { - if (cachedModdedPaths is not null) - { - moddedPaths = new Dictionary<(string GamePath, string? Hash), string>(cachedModdedPaths, cachedModdedPaths.Comparer); - } - else - { - int attempts = 0; - List toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); - missingReplacements = toDownloadReplacements; + int attempts = 0; + List toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); - while (toDownloadReplacements.Count > 0 && attempts++ <= 10 && !downloadToken.IsCancellationRequested) + while (toDownloadReplacements.Count > 0 && attempts++ <= 10 && !downloadToken.IsCancellationRequested) + { + if (_pairDownloadTask != null && !_pairDownloadTask.IsCompleted) { - if (_pairDownloadTask != null && !_pairDownloadTask.IsCompleted) - { - Logger.LogDebug("[BASE-{appBase}] Finishing prior running download task for player {name}, {kind}", applicationBase, PlayerName, updatedData); - await _pairDownloadTask.ConfigureAwait(false); - } - - Logger.LogDebug("[BASE-{appBase}] Downloading missing files for player {name}, {kind}", applicationBase, PlayerName, updatedData); - - Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational, - $"Starting download for {toDownloadReplacements.Count} files"))); - var toDownloadFiles = await _downloadManager.InitiateDownloadList(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false); - - if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, toDownloadFiles)) - { - RecordFailure("Auto pause triggered by VRAM usage thresholds", "VRAMThreshold"); - _downloadManager.ClearDownload(); - return; - } - - var handlerForDownload = _charaHandler; - _pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, toDownloadFiles, downloadToken, skipDownscaleForPair, skipDecimationForPair).ConfigureAwait(false)); - + Logger.LogDebug("[BASE-{appBase}] Finishing prior running download task for player {name}, {kind}", applicationBase, PlayerName, updatedData); await _pairDownloadTask.ConfigureAwait(false); - - if (downloadToken.IsCancellationRequested) - { - Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase); - RecordFailure("Download cancelled", "Cancellation"); - return; - } - - if (!skipDownscaleForPair) - { - var downloadedTextureHashes = toDownloadReplacements - .Where(static replacement => replacement.GamePaths.Any(static path => path.EndsWith(".tex", StringComparison.OrdinalIgnoreCase))) - .Select(static replacement => replacement.Hash) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (downloadedTextureHashes.Count > 0) - { - await _textureDownscaleService.WaitForPendingJobsAsync(downloadedTextureHashes, downloadToken).ConfigureAwait(false); - } - } - - if (!skipDecimationForPair) - { - var downloadedModelHashes = toDownloadReplacements - .Where(static replacement => replacement.GamePaths.Any(static path => path.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase))) - .Select(static replacement => replacement.Hash) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (downloadedModelHashes.Count > 0) - { - await _modelDecimationService.WaitForPendingJobsAsync(downloadedModelHashes, downloadToken).ConfigureAwait(false); - } - } - - toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); - missingReplacements = toDownloadReplacements; - - if (toDownloadReplacements.TrueForAll(c => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, c.Hash, StringComparison.Ordinal)))) - { - break; - } - - await Task.Delay(TimeSpan.FromSeconds(2), downloadToken).ConfigureAwait(false); } - if (!await _playerPerformanceService.CheckBothThresholds(this, charaData).ConfigureAwait(false)) + Logger.LogDebug("[BASE-{appBase}] Downloading missing files for player {name}, {kind}", applicationBase, PlayerName, updatedData); + + Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational, + $"Starting download for {toDownloadReplacements.Count} files"))); + var toDownloadFiles = await _downloadManager.InitiateDownloadList(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false); + + if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, toDownloadFiles)) { - RecordFailure("Auto pause triggered by performance thresholds", "PerformanceThreshold"); + RecordFailure("Auto pause triggered by VRAM usage thresholds", "VRAMThreshold"); + _downloadManager.ClearDownload(); return; } + + var handlerForDownload = _charaHandler; + _pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, toDownloadFiles, downloadToken, skipDownscaleForPair, skipDecimationForPair).ConfigureAwait(false)); + + await _pairDownloadTask.ConfigureAwait(false); + + if (downloadToken.IsCancellationRequested) + { + Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase); + RecordFailure("Download cancelled", "Cancellation"); + return; + } + + if (!skipDownscaleForPair) + { + var downloadedTextureHashes = toDownloadReplacements + .Where(static replacement => replacement.GamePaths.Any(static path => path.EndsWith(".tex", StringComparison.OrdinalIgnoreCase))) + .Select(static replacement => replacement.Hash) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (downloadedTextureHashes.Count > 0) + { + await _textureDownscaleService.WaitForPendingJobsAsync(downloadedTextureHashes, downloadToken).ConfigureAwait(false); + } + } + + if (!skipDecimationForPair) + { + var downloadedModelHashes = toDownloadReplacements + .Where(static replacement => replacement.GamePaths.Any(static path => path.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase))) + .Select(static replacement => replacement.Hash) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (downloadedModelHashes.Count > 0) + { + await _modelDecimationService.WaitForPendingJobsAsync(downloadedModelHashes, downloadToken).ConfigureAwait(false); + } + } + + toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); + + if (toDownloadReplacements.TrueForAll(c => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, c.Hash, StringComparison.Ordinal)))) + { + break; + } + + await Task.Delay(TimeSpan.FromSeconds(2), downloadToken).ConfigureAwait(false); } - } - else - { - moddedPaths = cachedModdedPaths is not null - ? new Dictionary<(string GamePath, string? Hash), string>(cachedModdedPaths, cachedModdedPaths.Comparer) - : []; - } - var wantsModApply = updateModdedPaths || updateManip; - var pendingModReapply = false; - var deferModApply = false; - - if (wantsModApply && missingReplacements.Count > 0) - { - CountMissingReplacements(missingReplacements, out var missingCritical, out var missingNonCritical, out var missingForbidden); - _lastMissingCriticalMods = missingCritical; - _lastMissingNonCriticalMods = missingNonCritical; - _lastMissingForbiddenMods = missingForbidden; - - var hasCriticalMissing = missingCritical > 0; - var hasNonCriticalMissing = missingNonCritical > 0; - var hasDownloadableMissing = missingReplacements.Any(replacement => !IsForbiddenHash(replacement.Hash)); - var hasDownloadableCriticalMissing = hasCriticalMissing - && missingReplacements.Any(replacement => !IsForbiddenHash(replacement.Hash) && IsCriticalModReplacement(replacement)); - - pendingModReapply = hasDownloadableMissing; - _lastModApplyDeferred = false; - - if (hasDownloadableCriticalMissing) + if (!await _playerPerformanceService.CheckBothThresholds(this, charaData).ConfigureAwait(false)) { - deferModApply = true; - _lastModApplyDeferred = true; - Logger.LogDebug("[BASE-{appBase}] Critical mod files missing for {handler}, deferring mod apply ({count} missing)", - applicationBase, GetLogIdentifier(), missingReplacements.Count); + RecordFailure("Auto pause triggered by performance thresholds", "PerformanceThreshold"); + return; } - else if (hasNonCriticalMissing && hasDownloadableMissing) - { - Logger.LogDebug("[BASE-{appBase}] Non-critical mod files missing for {handler}, applying partial mods and reapplying after downloads ({count} missing)", - applicationBase, GetLogIdentifier(), missingReplacements.Count); - } - } - else - { - _lastMissingCriticalMods = 0; - _lastMissingNonCriticalMods = 0; - _lastMissingForbiddenMods = 0; - _lastModApplyDeferred = false; } - if (deferModApply) - { - updateModdedPaths = false; - updateManip = false; - RemoveModApplyChanges(updatedData); - } + _pendingModReapply = false; + _lastMissingCriticalMods = 0; + _lastMissingNonCriticalMods = 0; + _lastMissingForbiddenMods = 0; + _lastModApplyDeferred = false; downloadToken.ThrowIfCancellationRequested(); @@ -2123,7 +1810,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger.LogDebug("[BASE-{appBase}] Handler not available for {player}, cached data for later application", applicationBase, GetLogIdentifier()); _cachedData = charaData; _pairStateCache.Store(Ident, charaData); - _forceFullReapply = true; RecordFailure("Handler not available for application", "HandlerUnavailable"); return; } @@ -2140,7 +1826,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (downloadToken.IsCancellationRequested || (appToken?.IsCancellationRequested ?? false)) { - _forceFullReapply = true; RecordFailure("Application cancelled", "Cancellation"); return; } @@ -2148,7 +1833,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource(); var token = _applicationCancellationTokenSource.Token; - _applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, wantsModApply, pendingModReapply, token); + _applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, token); } finally { @@ -2157,7 +1842,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } private async Task ApplyCharacterDataAsync(Guid applicationBase, GameObjectHandler handlerForApply, CharacterData charaData, Dictionary> updatedData, bool updateModdedPaths, bool updateManip, - Dictionary<(string GamePath, string? Hash), string> moddedPaths, bool wantsModApply, bool pendingModReapply, CancellationToken token) + Dictionary<(string GamePath, string? Hash), string> moddedPaths, CancellationToken token) { try { @@ -2175,7 +1860,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa applicationBase, GetLogIdentifier()); _cachedData = charaData; _pairStateCache.Store(Ident, charaData); - _forceFullReapply = true; RecordFailure("Actor not fully loaded within timeout", "FullyLoadedTimeout"); return; } @@ -2186,13 +1870,12 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Guid penumbraCollection = Guid.Empty; if (updateModdedPaths || updateManip) { - penumbraCollection = EnsurePenumbraCollection(); + penumbraCollection = await EnsurePenumbraCollectionAsync().ConfigureAwait(false); if (penumbraCollection == Guid.Empty) { Logger.LogTrace("[BASE-{applicationId}] Penumbra collection unavailable for {handler}, caching data for later application", applicationBase, GetLogIdentifier()); _cachedData = charaData; _pairStateCache.Store(Ident, charaData); - _forceFullReapply = true; RecordFailure("Penumbra collection unavailable", "PenumbraUnavailable"); return; } @@ -2200,7 +1883,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (updateModdedPaths) { - // ensure collection is set var objIndex = await _dalamudUtil.RunOnFrameworkThread(() => { var gameObject = handlerForApply.GetGameObject(); @@ -2212,25 +1894,20 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger.LogDebug("[BASE-{applicationId}] GameObject not available for {handler}, caching data for later application", applicationBase, GetLogIdentifier()); _cachedData = charaData; _pairStateCache.Store(Ident, charaData); - _forceFullReapply = true; RecordFailure("Game object not available for application", "GameObjectUnavailable"); return; } + await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, objIndex.Value).ConfigureAwait(false); + SplitPapMappings(moddedPaths, out var withoutPap, out var papOnly); var hasPap = papOnly.Count > 0; - await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, objIndex.Value).ConfigureAwait(false); - await _ipcManager.Penumbra.SetTemporaryModsAsync( Logger, _applicationId, penumbraCollection, withoutPap.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)) .ConfigureAwait(false); - await _ipcManager.Penumbra.RedrawAsync(Logger, handlerForApply, _applicationId, token).ConfigureAwait(false); - if (handlerForApply.Address != nint.Zero) - await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token).ConfigureAwait(false); - if (hasPap) { var removedPap = await StripIncompatiblePapAsync(handlerForApply, charaData, papOnly, token).ConfigureAwait(false); @@ -2247,12 +1924,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger, _applicationId, penumbraCollection, merged.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)) .ConfigureAwait(false); - - _lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(merged, merged.Comparer); - } - else - { - _lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(withoutPap, withoutPap.Comparer); } LastAppliedDataBytes = -1; @@ -2287,12 +1958,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _cachedData = charaData; _pairStateCache.Store(Ident, charaData); - if (wantsModApply) - { - _pendingModReapply = pendingModReapply; - } - _forceFullReapply = _pendingModReapply; - _needsCollectionRebuild = false; if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0) { _playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List()); @@ -2304,7 +1969,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } StorePerformanceMetrics(charaData); - _lastSuccessfulDataHash = GetDataHashSafe(charaData); _lastSuccessfulApplyAt = DateTime.UtcNow; ClearFailureState(); Logger.LogDebug("[{applicationId}] Application finished", _applicationId); @@ -2314,7 +1978,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger.LogDebug("[{applicationId}] Application cancelled for {handler}", _applicationId, GetLogIdentifier()); _cachedData = charaData; _pairStateCache.Store(Ident, charaData); - _forceFullReapply = true; RecordFailure("Application cancelled", "Cancellation"); } catch (Exception ex) @@ -2325,13 +1988,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _forceApplyMods = true; _cachedData = charaData; _pairStateCache.Store(Ident, charaData); - _forceFullReapply = true; Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId); } else { Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId); - _forceFullReapply = true; } RecordFailure($"Application failed: {ex.Message}", "Exception"); } @@ -2375,7 +2036,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { try { - _forceFullReapply = true; ApplyCharacterData(appData, cachedData!, forceApplyCustomization: true); } catch (Exception ex) @@ -2392,7 +2052,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { try { - _forceFullReapply = true; ApplyLastReceivedData(forced: true); } catch (Exception ex) @@ -2456,6 +2115,29 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _ = ReapplyPetNamesAsync(petNamesData!); }); + + var handlerForAssign = _charaHandler; + _ = Task.Run(async () => + { + if (handlerForAssign is null) + { + return; + } + + var penumbraCollection = await EnsurePenumbraCollectionAsync().ConfigureAwait(false); + if (penumbraCollection == Guid.Empty) + { + return; + } + + var objIndex = await _dalamudUtil.RunOnFrameworkThread(() => handlerForAssign.GetGameObject()?.ObjectIndex) + .ConfigureAwait(false); + if (objIndex.HasValue) + { + await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, objIndex.Value) + .ConfigureAwait(false); + } + }); } private async Task ReapplyHonorificAsync(string honorificData) @@ -2652,6 +2334,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { Logger.LogDebug("Pausing handler {handler}", GetLogIdentifier()); DisableSync(); + _wasRevertedOnPause = false; if (_charaHandler is null || _charaHandler.Address == nint.Zero) { @@ -2660,7 +2343,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } var applicationId = Guid.NewGuid(); - await RevertToRestoredAsync(applicationId).ConfigureAwait(false); + _wasRevertedOnPause = await RevertToRestoredAsync(applicationId).ConfigureAwait(false); IsVisible = false; } catch (Exception ex) @@ -2684,9 +2367,12 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa IsVisible = true; } + var forceApply = _wasRevertedOnPause; + _wasRevertedOnPause = false; + if (LastReceivedCharacterData is not null) { - ApplyLastReceivedData(forced: true); + ApplyLastReceivedData(forced: forceApply); } } catch (Exception ex) @@ -2695,29 +2381,31 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } } - private async Task RevertToRestoredAsync(Guid applicationId) + private async Task RevertToRestoredAsync(Guid applicationId) { if (_charaHandler is null || _charaHandler.Address == nint.Zero) { - return; + return false; } try { + var reverted = false; var gameObject = await _dalamudUtil.RunOnFrameworkThread(() => _charaHandler.GetGameObject()).ConfigureAwait(false); if (gameObject is not Dalamud.Game.ClientState.Objects.Types.ICharacter character) { - return; + return false; } if (_ipcManager.Penumbra.APIAvailable) { - var penumbraCollection = EnsurePenumbraCollection(); + var penumbraCollection = await EnsurePenumbraCollectionAsync().ConfigureAwait(false); if (penumbraCollection != Guid.Empty) { await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, character.ObjectIndex).ConfigureAwait(false); await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, applicationId, penumbraCollection, new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); await _ipcManager.Penumbra.SetManipulationDataAsync(Logger, applicationId, penumbraCollection, string.Empty).ConfigureAwait(false); + reverted = true; } } @@ -2740,25 +2428,23 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (string.IsNullOrEmpty(characterName)) { Logger.LogWarning("[{applicationId}] Failed to determine character name for {handler} while reverting", applicationId, GetLogIdentifier()); - return; + return reverted; } foreach (var kind in kinds) { await RevertCustomizationDataAsync(kind, characterName, applicationId, CancellationToken.None).ConfigureAwait(false); + reverted = true; } - _cachedData = null; - LastAppliedDataBytes = -1; - LastAppliedDataTris = -1; - LastAppliedApproximateEffectiveTris = -1; - LastAppliedApproximateVRAMBytes = -1; - LastAppliedApproximateEffectiveVRAMBytes = -1; + return reverted; } catch (Exception ex) { Logger.LogWarning(ex, "Failed to revert handler {handler} during pause", GetLogIdentifier()); } + + return false; } private void DisableSync() diff --git a/LightlessSync/Services/PenumbraTempCollectionJanitor.cs b/LightlessSync/Services/PenumbraTempCollectionJanitor.cs index 03fb53b..87d37ac 100644 --- a/LightlessSync/Services/PenumbraTempCollectionJanitor.cs +++ b/LightlessSync/Services/PenumbraTempCollectionJanitor.cs @@ -1,4 +1,6 @@ -using LightlessSync.Interop.Ipc; +using System.Linq; +using LightlessSync.Interop.Ipc; +using LightlessSync.LightlessConfiguration.Models; using LightlessSync.LightlessConfiguration; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; @@ -10,6 +12,7 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber private readonly IpcManager _ipc; private readonly LightlessConfigService _config; private int _ran; + private static readonly TimeSpan OrphanCleanupDelay = TimeSpan.FromDays(1); public PenumbraTempCollectionJanitor( ILogger logger, @@ -26,15 +29,46 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber public void Register(Guid id) { if (id == Guid.Empty) return; - if (_config.Current.OrphanableTempCollections.Add(id)) + var changed = false; + var config = _config.Current; + if (config.OrphanableTempCollections.Add(id)) + { + changed = true; + } + + var now = DateTime.UtcNow; + var existing = config.OrphanableTempCollectionEntries.FirstOrDefault(entry => entry.Id == id); + if (existing is null) + { + config.OrphanableTempCollectionEntries.Add(new OrphanableTempCollectionEntry + { + Id = id, + RegisteredAtUtc = now + }); + changed = true; + } + else if (existing.RegisteredAtUtc == DateTime.MinValue) + { + existing.RegisteredAtUtc = now; + changed = true; + } + + if (changed) + { _config.Save(); + } } public void Unregister(Guid id) { if (id == Guid.Empty) return; - if (_config.Current.OrphanableTempCollections.Remove(id)) + var config = _config.Current; + var changed = config.OrphanableTempCollections.Remove(id); + changed |= RemoveEntry(config.OrphanableTempCollectionEntries, id) > 0; + if (changed) + { _config.Save(); + } } private void CleanupOrphansOnBoot() @@ -45,14 +79,33 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber if (!_ipc.Penumbra.APIAvailable) return; - var ids = _config.Current.OrphanableTempCollections.ToArray(); - if (ids.Length == 0) + var config = _config.Current; + var ids = config.OrphanableTempCollections; + var entries = config.OrphanableTempCollectionEntries; + if (ids.Count == 0 && entries.Count == 0) return; - var appId = Guid.NewGuid(); - Logger.LogInformation("Cleaning up {count} orphaned Lightless temp collections found in configuration", ids.Length); + var now = DateTime.UtcNow; + var changed = EnsureEntries(ids, entries, now); + var cutoff = now - OrphanCleanupDelay; + var expired = entries + .Where(entry => entry.Id != Guid.Empty && entry.RegisteredAtUtc != DateTime.MinValue && entry.RegisteredAtUtc <= cutoff) + .Select(entry => entry.Id) + .Distinct() + .ToList(); + if (expired.Count == 0) + { + if (changed) + { + _config.Save(); + } + return; + } - foreach (var id in ids) + var appId = Guid.NewGuid(); + Logger.LogInformation("Cleaning up {count} orphaned Lightless temp collections older than {delay}", expired.Count, OrphanCleanupDelay); + + foreach (var id in expired) { try { @@ -65,7 +118,70 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber } } - _config.Current.OrphanableTempCollections.Clear(); + foreach (var id in expired) + { + ids.Remove(id); + } + + foreach (var id in expired) + { + RemoveEntry(entries, id); + } + _config.Save(); } -} \ No newline at end of file + + private static int RemoveEntry(List entries, Guid id) + { + var removed = 0; + for (var i = entries.Count - 1; i >= 0; i--) + { + if (entries[i].Id != id) + { + continue; + } + + entries.RemoveAt(i); + removed++; + } + + return removed; + } + + private static bool EnsureEntries(HashSet ids, List entries, DateTime now) + { + var changed = false; + foreach (var id in ids) + { + if (id == Guid.Empty) + { + continue; + } + + if (entries.Any(entry => entry.Id == id)) + { + continue; + } + + entries.Add(new OrphanableTempCollectionEntry + { + Id = id, + RegisteredAtUtc = now + }); + changed = true; + } + + foreach (var entry in entries) + { + if (entry.Id == Guid.Empty || entry.RegisteredAtUtc != DateTime.MinValue) + { + continue; + } + + entry.RegisteredAtUtc = now; + changed = true; + } + + return changed; + } +} diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index bc31556..d1bf52e 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -1485,8 +1485,6 @@ public class SettingsUi : WindowMediatorSubscriberBase DrawPairPropertyRow("Has Handler", FormatBool(debugInfo.HasHandler)); DrawPairPropertyRow("Handler Initialized", FormatBool(debugInfo.HandlerInitialized)); DrawPairPropertyRow("Handler Visible", FormatBool(debugInfo.HandlerVisible)); - DrawPairPropertyRow("Last Time person rendered in", FormatTimestamp(debugInfo.InvisibleSinceUtc)); - DrawPairPropertyRow("Handler Timer Temp Collection removal", FormatCountdown(debugInfo.VisibilityEvictionRemainingSeconds)); DrawPairPropertyRow("Handler Scheduled For Deletion", FormatBool(debugInfo.HandlerScheduledForDeletion)); DrawPairPropertyRow("Note", pair.GetNote() ?? "(none)"); @@ -1622,8 +1620,6 @@ public class SettingsUi : WindowMediatorSubscriberBase sb.AppendLine($"Has Handler: {FormatBool(debugInfo.HasHandler)}"); sb.AppendLine($"Handler Initialized: {FormatBool(debugInfo.HandlerInitialized)}"); sb.AppendLine($"Handler Visible: {FormatBool(debugInfo.HandlerVisible)}"); - sb.AppendLine($"Last Time person rendered in: {FormatTimestamp(debugInfo.InvisibleSinceUtc)}"); - sb.AppendLine($"Handler Timer Temp Collection removal: {FormatCountdown(debugInfo.VisibilityEvictionRemainingSeconds)}"); sb.AppendLine($"Handler Scheduled For Deletion: {FormatBool(debugInfo.HandlerScheduledForDeletion)}"); sb.AppendLine($"Note: {pair.GetNote() ?? "(none)"}"); -- 2.49.1 From 6c7e4e630399395f3babc7f13f77d24a106f6b2e Mon Sep 17 00:00:00 2001 From: azyges Date: Fri, 16 Jan 2026 19:18:11 +0900 Subject: [PATCH 69/87] fix task register --- .../PlayerData/Factories/PlayerDataFactory.cs | 25 ++--- LightlessSync/Utils/TaskRegistry.cs | 102 +++++++++++------- 2 files changed, 70 insertions(+), 57 deletions(-) diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index 51dba8f..2a45fbf 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -9,10 +9,10 @@ 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.ExceptionServices; using System.Runtime.InteropServices; namespace LightlessSync.PlayerData.Factories; @@ -34,7 +34,7 @@ public class PlayerDataFactory private const int _maxTransientResolvedEntries = 1000; // Character build caches - private readonly ConcurrentDictionary> _characterBuildInflight = new(); + private readonly TaskRegistry _characterBuildInflight = new(); private readonly ConcurrentDictionary _characterBuildCache = new(); // Time out thresholds @@ -170,10 +170,10 @@ public class PlayerDataFactory { var key = obj.Address; - if (_characterBuildCache.TryGetValue(key, out var cached) && IsCacheFresh(cached) && !_characterBuildInflight.ContainsKey(key)) + if (_characterBuildCache.TryGetValue(key, out CacheEntry cached) && IsCacheFresh(cached) && !_characterBuildInflight.TryGetExisting(key, out _)) return cached.Fragment; - var buildTask = _characterBuildInflight.GetOrAdd(key, _ => BuildAndCacheAsync(obj, key)); + Task buildTask = _characterBuildInflight.GetOrStart(key, () => BuildAndCacheAsync(obj, key)); if (_characterBuildCache.TryGetValue(key, out cached)) { @@ -189,20 +189,13 @@ public class PlayerDataFactory private async Task BuildAndCacheAsync(GameObjectHandler obj, nint key) { - try - { - using var cts = new CancellationTokenSource(_hardBuildTimeout); - var fragment = await CreateCharacterDataInternal(obj, cts.Token).ConfigureAwait(false); + using var cts = new CancellationTokenSource(_hardBuildTimeout); + CharacterDataFragment fragment = await CreateCharacterDataInternal(obj, cts.Token).ConfigureAwait(false); - _characterBuildCache[key] = new CacheEntry(fragment, DateTime.UtcNow); - PruneCharacterCacheIfNeeded(); + _characterBuildCache[key] = new CacheEntry(fragment, DateTime.UtcNow); + PruneCharacterCacheIfNeeded(); - return fragment; - } - finally - { - _characterBuildInflight.TryRemove(key, out _); - } + return fragment; } private void PruneCharacterCacheIfNeeded() diff --git a/LightlessSync/Utils/TaskRegistry.cs b/LightlessSync/Utils/TaskRegistry.cs index d888bbd..90b6fcf 100644 --- a/LightlessSync/Utils/TaskRegistry.cs +++ b/LightlessSync/Utils/TaskRegistry.cs @@ -1,37 +1,81 @@ using System.Collections.Concurrent; - namespace LightlessSync.Utils; public sealed class TaskRegistry where HandleType : notnull { - private readonly ConcurrentDictionary _activeTasks = new(); + private readonly ConcurrentDictionary> _activeTasks = new(); public Task GetOrStart(HandleType handle, Func taskFactory) - { - ActiveTask entry = _activeTasks.GetOrAdd(handle, i => new ActiveTask(() => ExecuteAndRemove(i, taskFactory))); - return entry.EnsureStarted(); - } + => GetOrStartInternal(handle, taskFactory); public Task GetOrStart(HandleType handle, Func> taskFactory) - { - ActiveTask entry = _activeTasks.GetOrAdd(handle, i => new ActiveTask(() => ExecuteAndRemove(i, taskFactory))); - return (Task)entry.EnsureStarted(); - } + => GetOrStartInternal(handle, taskFactory); public bool TryGetExisting(HandleType handle, out Task task) { - if (_activeTasks.TryGetValue(handle, out ActiveTask? entry)) + if (_activeTasks.TryGetValue(handle, out Lazy? entry)) { - task = entry.EnsureStarted(); - return true; + task = entry.Value; + if (!task.IsCompleted) + { + return true; + } + + _activeTasks.TryRemove(new KeyValuePair>(handle, entry)); } task = Task.CompletedTask; return false; } - private async Task ExecuteAndRemove(HandleType handle, Func taskFactory) + private Task GetOrStartInternal(HandleType handle, Func taskFactory) + { + while (true) + { + Lazy entry = _activeTasks.GetOrAdd(handle, _ => CreateEntry(handle, taskFactory)); + Task task = entry.Value; + + if (!task.IsCompleted) + { + return task; + } + + _activeTasks.TryRemove(new KeyValuePair>(handle, entry)); + } + } + + private Task GetOrStartInternal(HandleType handle, Func> taskFactory) + { + while (true) + { + Lazy entry = _activeTasks.GetOrAdd(handle, _ => CreateEntry(handle, taskFactory)); + Task task = entry.Value; + + if (!task.IsCompleted) + { + return (Task)task; + } + + _activeTasks.TryRemove(new KeyValuePair>(handle, entry)); + } + } + + private Lazy CreateEntry(HandleType handle, Func taskFactory) + { + Lazy entry = null!; + entry = new Lazy(() => ExecuteAndRemove(handle, entry, taskFactory), LazyThreadSafetyMode.ExecutionAndPublication); + return entry; + } + + private Lazy CreateEntry(HandleType handle, Func> taskFactory) + { + Lazy entry = null!; + entry = new Lazy(() => ExecuteAndRemove(handle, entry, taskFactory), LazyThreadSafetyMode.ExecutionAndPublication); + return entry; + } + + private async Task ExecuteAndRemove(HandleType handle, Lazy entry, Func taskFactory) { try { @@ -39,11 +83,11 @@ public sealed class TaskRegistry where HandleType : notnull } finally { - _activeTasks.TryRemove(handle, out _); + _activeTasks.TryRemove(new KeyValuePair>(handle, entry)); } } - private async Task ExecuteAndRemove(HandleType handle, Func> taskFactory) + private async Task ExecuteAndRemove(HandleType handle, Lazy entry, Func> taskFactory) { try { @@ -51,31 +95,7 @@ public sealed class TaskRegistry where HandleType : notnull } finally { - _activeTasks.TryRemove(handle, out _); - } - } - - private sealed class ActiveTask - { - private readonly object _gate = new(); - private readonly Func _starter; - private Task? _cached; - - public ActiveTask(Func starter) - { - _starter = starter; - } - - public Task EnsureStarted() - { - lock (_gate) - { - if (_cached == null || _cached.IsCompleted) - { - _cached = _starter(); - } - return _cached; - } + _activeTasks.TryRemove(new KeyValuePair>(handle, entry)); } } } -- 2.49.1 From 7c281926a51586e4d1d32b1f46c7123e507cb3b0 Mon Sep 17 00:00:00 2001 From: azyges Date: Fri, 16 Jan 2026 19:29:24 +0900 Subject: [PATCH 70/87] :sludge: --- LightlessSync/Utils/TaskRegistry.cs | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/LightlessSync/Utils/TaskRegistry.cs b/LightlessSync/Utils/TaskRegistry.cs index 90b6fcf..7104548 100644 --- a/LightlessSync/Utils/TaskRegistry.cs +++ b/LightlessSync/Utils/TaskRegistry.cs @@ -31,34 +31,26 @@ public sealed class TaskRegistry where HandleType : notnull private Task GetOrStartInternal(HandleType handle, Func taskFactory) { - while (true) + Lazy entry = _activeTasks.GetOrAdd(handle, _ => CreateEntry(handle, taskFactory)); + Task task = entry.Value; + if (task.IsCompleted) { - Lazy entry = _activeTasks.GetOrAdd(handle, _ => CreateEntry(handle, taskFactory)); - Task task = entry.Value; - - if (!task.IsCompleted) - { - return task; - } - _activeTasks.TryRemove(new KeyValuePair>(handle, entry)); } + + return task; } private Task GetOrStartInternal(HandleType handle, Func> taskFactory) { - while (true) + Lazy entry = _activeTasks.GetOrAdd(handle, _ => CreateEntry(handle, taskFactory)); + Task task = entry.Value; + if (task.IsCompleted) { - Lazy entry = _activeTasks.GetOrAdd(handle, _ => CreateEntry(handle, taskFactory)); - Task task = entry.Value; - - if (!task.IsCompleted) - { - return (Task)task; - } - _activeTasks.TryRemove(new KeyValuePair>(handle, entry)); } + + return (Task)task; } private Lazy CreateEntry(HandleType handle, Func taskFactory) -- 2.49.1 From 8be0811b4a67c43cb640967b2f3826cb9da862b2 Mon Sep 17 00:00:00 2001 From: azyges Date: Sat, 17 Jan 2026 03:06:46 +0900 Subject: [PATCH 71/87] fix pair offline state --- LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs | 2 +- LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs | 1 + LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs | 4 ++++ LightlessSync/PlayerData/Pairs/PairManager.cs | 6 ++++-- LightlessSync/PlayerData/Pairs/PairModels.cs | 1 + 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs b/LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs index 713333e..6a5cb45 100644 --- a/LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs +++ b/LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs @@ -137,7 +137,7 @@ public sealed partial class PairCoordinator _pendingCharacterData.TryRemove(user.UID, out _); if (registrationResult.Value.CharacterIdent is not null) { - _ = _handlerRegistry.DeregisterOfflinePair(registrationResult.Value); + _ = _handlerRegistry.DeregisterOfflinePair(registrationResult.Value, forceDisposal: true); } _mediator.Publish(new ClearProfileUserDataMessage(user)); diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index 9a3683f..7b3708f 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -1363,6 +1363,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { PlayerName = null; _cachedData = null; + LastReceivedCharacterData = null; _performanceMetricsCache.Clear(Ident); Logger.LogDebug("Disposing {name} complete", name); } diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs index 881c35c..9ffcd1f 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs @@ -136,6 +136,7 @@ public sealed class PairHandlerRegistry : IDisposable if (TryFinalizeHandlerRemoval(handler)) { handler.Dispose(); + _pairStateCache.Clear(registration.CharacterIdent); } } else if (shouldScheduleRemoval && handler is not null) @@ -356,6 +357,7 @@ public sealed class PairHandlerRegistry : IDisposable finally { _pairPerformanceMetricsCache.Clear(handler.Ident); + _pairStateCache.Clear(handler.Ident); } } } @@ -377,6 +379,7 @@ public sealed class PairHandlerRegistry : IDisposable { handler.Dispose(); _pairPerformanceMetricsCache.Clear(handler.Ident); + _pairStateCache.Clear(handler.Ident); } } @@ -401,6 +404,7 @@ public sealed class PairHandlerRegistry : IDisposable if (TryFinalizeHandlerRemoval(handler)) { handler.Dispose(); + _pairStateCache.Clear(handler.Ident); } } diff --git a/LightlessSync/PlayerData/Pairs/PairManager.cs b/LightlessSync/PlayerData/Pairs/PairManager.cs index eb70a54..0a18a9d 100644 --- a/LightlessSync/PlayerData/Pairs/PairManager.cs +++ b/LightlessSync/PlayerData/Pairs/PairManager.cs @@ -160,8 +160,9 @@ public sealed class PairManager return PairOperationResult.Fail($"Pair {user.UID} not found."); } + var ident = connection.Ident; connection.SetOffline(); - return PairOperationResult.Ok(new PairRegistration(new PairUniqueIdentifier(user.UID), connection.Ident)); + return PairOperationResult.Ok(new PairRegistration(new PairUniqueIdentifier(user.UID), ident)); } } @@ -530,6 +531,7 @@ public sealed class PairManager return null; } + var ident = connection.Ident; if (connection.IsOnline) { connection.SetOffline(); @@ -542,7 +544,7 @@ public sealed class PairManager shell.Users.Remove(userId); } - return new PairRegistration(new PairUniqueIdentifier(userId), connection.Ident); + return new PairRegistration(new PairUniqueIdentifier(userId), ident); } public static PairConnection CreateFromFullData(UserFullPairDto dto) diff --git a/LightlessSync/PlayerData/Pairs/PairModels.cs b/LightlessSync/PlayerData/Pairs/PairModels.cs index 9f34ab2..3b37ce2 100644 --- a/LightlessSync/PlayerData/Pairs/PairModels.cs +++ b/LightlessSync/PlayerData/Pairs/PairModels.cs @@ -76,6 +76,7 @@ public sealed class PairConnection public void SetOffline() { IsOnline = false; + Ident = null; } public void UpdatePermissions(UserPermissions own, UserPermissions other) -- 2.49.1 From b57d54d69c7362764a74676dbb3454ae1f0f4ca7 Mon Sep 17 00:00:00 2001 From: azyges Date: Sat, 17 Jan 2026 08:00:58 +0900 Subject: [PATCH 72/87] fix some existing issues --- .../PlayerData/Pairs/PairHandlerAdapter.cs | 162 ++++++++++++++++-- .../Services/PerformanceCollectorService.cs | 11 +- .../Components/OptimizationSettingsPanel.cs | 13 ++ LightlessSync/Utils/RollingList.cs | 23 +++ 4 files changed, 191 insertions(+), 18 deletions(-) diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index 7b3708f..398d96c 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -60,6 +60,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private Guid _applicationId; private Task? _applicationTask; private CharacterData? _cachedData = null; + private CharacterData? _lastAppliedData = null; private GameObjectHandler? _charaHandler; private readonly Dictionary _customizeIds = []; private CombatData? _dataReceivedInDowntime; @@ -256,6 +257,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } Mediator.Subscribe(this, _ => { + LogDownloadCancellation("zone switch start"); _downloadCancellationTokenSource?.CancelDispose(); _charaHandler?.Invalidate(); IsVisible = false; @@ -385,6 +387,42 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return string.Equals(alias, Ident, StringComparison.Ordinal) ? alias : $"{alias} ({Ident})"; } + private void LogDownloadCancellation(string reason, Guid? applicationBase = null) + { + if (_downloadCancellationTokenSource is null) + { + return; + } + + var inFlight = _pairDownloadTask is { IsCompleted: false }; + if (inFlight) + { + if (applicationBase.HasValue) + { + Logger.LogDebug("[BASE-{appBase}] {handler}: Cancelling in-flight download ({reason})", + applicationBase.Value, GetLogIdentifier(), reason); + } + else + { + Logger.LogDebug("{handler}: Cancelling in-flight download ({reason})", + GetLogIdentifier(), reason); + } + } + else + { + if (applicationBase.HasValue) + { + Logger.LogDebug("[BASE-{appBase}] {handler}: Cancelling download token ({reason}, in-flight={inFlight})", + applicationBase.Value, GetLogIdentifier(), reason, inFlight); + } + else + { + Logger.LogDebug("{handler}: Cancelling download token ({reason}, in-flight={inFlight})", + GetLogIdentifier(), reason, inFlight); + } + } + } + private Task EnsurePenumbraCollectionAsync() { if (_penumbraCollection != Guid.Empty) @@ -851,6 +889,40 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private bool IsForbiddenHash(string hash) => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, hash, StringComparison.Ordinal)); + private bool HasMissingFiles(CharacterData data) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var replacement in data.FileReplacements.SelectMany(k => k.Value)) + { + if (!string.IsNullOrEmpty(replacement.FileSwapPath)) + { + continue; + } + + var hash = replacement.Hash; + if (string.IsNullOrWhiteSpace(hash) || !seen.Add(hash)) + { + continue; + } + + var fileCache = _fileDbManager.GetFileCacheByHash(hash); + if (fileCache is null || !File.Exists(fileCache.ResolvedFilepath)) + { + if (fileCache is not null) + { + _fileDbManager.RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath); + } + + if (!IsForbiddenHash(hash)) + { + return true; + } + } + } + + return false; + } + private static bool IsNonPriorityModPath(string? gamePath) { if (string.IsNullOrEmpty(gamePath)) @@ -1012,10 +1084,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa "Cannot apply character data: Receiving Player is in an invalid state, deferring application"))); Logger.LogDebug("[BASE-{appBase}] Received data but player was in invalid state, charaHandlerIsNull: {charaIsNull}, playerPointerIsNull: {ptrIsNull}", applicationBase, _charaHandler == null, PlayerCharacter == IntPtr.Zero); - var hasDiffMods = characterData.CheckUpdatedData(applicationBase, _cachedData, Logger, + var hasDiffMods = characterData.CheckUpdatedData(applicationBase, _lastAppliedData, Logger, this, forceApplyCustomization, forceApplyMods: false) .Any(p => p.Value.Contains(PlayerChanges.ModManip) || p.Value.Contains(PlayerChanges.ModFiles)); - _forceApplyMods = hasDiffMods || _forceApplyMods || _cachedData == null; + _forceApplyMods = hasDiffMods || _forceApplyMods || _lastAppliedData == null; + _pendingModReapply = true; _cachedData = characterData; Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods); return; @@ -1023,27 +1096,34 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa SetUploading(false); + Logger.LogDebug("[BASE-{appbase}] Applying data for {player}, forceApplyCustomization: {forced}, forceApplyMods: {forceMods}", applicationBase, GetLogIdentifier(), forceApplyCustomization, _forceApplyMods); + Logger.LogDebug("[BASE-{appbase}] Hash for data is {newHash}, last applied hash is {oldHash}", applicationBase, characterData.DataHash.Value, _lastAppliedData?.DataHash.Value ?? "NODATA"); + + var hasMissingFiles = false; + if (string.Equals(characterData.DataHash.Value, _lastAppliedData?.DataHash.Value ?? string.Empty, StringComparison.Ordinal) + && !forceApplyCustomization + && !_forceApplyMods + && !_pendingModReapply) + { + hasMissingFiles = HasMissingFiles(characterData); + if (!hasMissingFiles) + { + return; + } + } + _pendingModReapply = false; _lastModApplyDeferred = false; _lastMissingCriticalMods = 0; _lastMissingNonCriticalMods = 0; _lastMissingForbiddenMods = 0; - Logger.LogDebug("[BASE-{appbase}] Applying data for {player}, forceApplyCustomization: {forced}, forceApplyMods: {forceMods}", applicationBase, GetLogIdentifier(), forceApplyCustomization, _forceApplyMods); - Logger.LogDebug("[BASE-{appbase}] Hash for data is {newHash}, current cache hash is {oldHash}", applicationBase, characterData.DataHash.Value, _cachedData?.DataHash.Value ?? "NODATA"); - - if (string.Equals(characterData.DataHash.Value, _cachedData?.DataHash.Value ?? string.Empty, StringComparison.Ordinal) - && !forceApplyCustomization) - { - return; - } - Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational, "Applying Character Data"))); - _forceApplyMods |= forceApplyCustomization; + _forceApplyMods |= forceApplyCustomization || hasMissingFiles; - var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this, + var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _lastAppliedData?.DeepClone() ?? new(), Logger, this, forceApplyCustomization, _forceApplyMods, suppressForcedModRedraw); if (handlerReady && _forceApplyMods) @@ -1282,6 +1362,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Guid applicationId = Guid.NewGuid(); _applicationCancellationTokenSource?.CancelDispose(); _applicationCancellationTokenSource = null; + LogDownloadCancellation("dispose"); _downloadCancellationTokenSource?.CancelDispose(); _downloadCancellationTokenSource = null; ClearAllOwnedObjectRetries(); @@ -1363,6 +1444,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { PlayerName = null; _cachedData = null; + _lastAppliedData = null; LastReceivedCharacterData = null; _performanceMetricsCache.Clear(Ident); Logger.LogDebug("Disposing {name} complete", name); @@ -1695,6 +1777,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa var updateModdedPaths = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModFiles)); var updateManip = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModManip)); + LogDownloadCancellation("new download request", applicationBase); _downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource(); var downloadToken = _downloadCancellationTokenSource.Token; _ = DownloadAndApplyCharacterAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, downloadToken) @@ -1741,13 +1824,14 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } var handlerForDownload = _charaHandler; - _pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, toDownloadFiles, downloadToken, skipDownscaleForPair, skipDecimationForPair).ConfigureAwait(false)); + _pairDownloadTask = _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, toDownloadFiles, downloadToken, skipDownscaleForPair, skipDecimationForPair); await _pairDownloadTask.ConfigureAwait(false); if (downloadToken.IsCancellationRequested) { Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase); + _pendingModReapply = true; RecordFailure("Download cancelled", "Cancellation"); return; } @@ -1809,6 +1893,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (handlerForApply is null || handlerForApply.Address == nint.Zero) { Logger.LogDebug("[BASE-{appBase}] Handler not available for {player}, cached data for later application", applicationBase, GetLogIdentifier()); + _pendingModReapply = true; _cachedData = charaData; _pairStateCache.Store(Ident, charaData); RecordFailure("Handler not available for application", "HandlerUnavailable"); @@ -1836,6 +1921,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, token); } + catch (OperationCanceledException) when (downloadToken.IsCancellationRequested) + { + _pendingModReapply = true; + RecordFailure("Download cancelled", "Cancellation"); + } finally { await concurrencyLease.DisposeAsync().ConfigureAwait(false); @@ -1859,6 +1949,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { Logger.LogDebug("[BASE-{applicationId}] Timed out waiting for {handler} to fully load, caching data for later application", applicationBase, GetLogIdentifier()); + _pendingModReapply = true; _cachedData = charaData; _pairStateCache.Store(Ident, charaData); RecordFailure("Actor not fully loaded within timeout", "FullyLoadedTimeout"); @@ -1875,6 +1966,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (penumbraCollection == Guid.Empty) { Logger.LogTrace("[BASE-{applicationId}] Penumbra collection unavailable for {handler}, caching data for later application", applicationBase, GetLogIdentifier()); + _pendingModReapply = true; _cachedData = charaData; _pairStateCache.Store(Ident, charaData); RecordFailure("Penumbra collection unavailable", "PenumbraUnavailable"); @@ -1893,6 +1985,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (!objIndex.HasValue) { Logger.LogDebug("[BASE-{applicationId}] GameObject not available for {handler}, caching data for later application", applicationBase, GetLogIdentifier()); + _pendingModReapply = true; _cachedData = charaData; _pairStateCache.Store(Ident, charaData); RecordFailure("Game object not available for application", "GameObjectUnavailable"); @@ -1958,6 +2051,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } _cachedData = charaData; + _lastAppliedData = charaData; _pairStateCache.Store(Ident, charaData); if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0) { @@ -1977,6 +2071,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa catch (OperationCanceledException) { Logger.LogDebug("[{applicationId}] Application cancelled for {handler}", _applicationId, GetLogIdentifier()); + _pendingModReapply = true; _cachedData = charaData; _pairStateCache.Store(Ident, charaData); RecordFailure("Application cancelled", "Cancellation"); @@ -2072,6 +2167,25 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } TryApplyQueuedData(); + + if (_pendingModReapply && IsVisible && !IsApplying && LastReceivedCharacterData is not null && CanApplyNow()) + { + var now = DateTime.UtcNow; + if (!_lastApplyAttemptAt.HasValue || now - _lastApplyAttemptAt.Value > TimeSpan.FromSeconds(5)) + { + _ = Task.Run(() => + { + try + { + ApplyLastReceivedData(forced: true); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to reapply pending data for {handler}", GetLogIdentifier()); + } + }); + } + } } private void HandleVisibilityLoss(bool logChange) @@ -2079,6 +2193,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa IsVisible = false; _charaHandler?.Invalidate(); ClearAllOwnedObjectRetries(); + LogDownloadCancellation("visibility lost"); _downloadCancellationTokenSource?.CancelDispose(); _downloadCancellationTokenSource = null; if (logChange) @@ -2384,7 +2499,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private async Task RevertToRestoredAsync(Guid applicationId) { - if (_charaHandler is null || _charaHandler.Address == nint.Zero) + var handler = _charaHandler; + if (handler is null || handler.Address == nint.Zero) { return false; } @@ -2392,7 +2508,15 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa try { var reverted = false; - var gameObject = await _dalamudUtil.RunOnFrameworkThread(() => _charaHandler.GetGameObject()).ConfigureAwait(false); + var gameObject = await _dalamudUtil.RunOnFrameworkThread(() => + { + if (handler.Address == nint.Zero) + { + return null; + } + + return handler.GetGameObject(); + }).ConfigureAwait(false); if (gameObject is not Dalamud.Game.ClientState.Objects.Types.ICharacter character) { return false; @@ -2450,6 +2574,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private void DisableSync() { + LogDownloadCancellation("sync disabled"); _downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate(); _applicationCancellationTokenSource = _applicationCancellationTokenSource?.CancelRecreate(); } @@ -2457,6 +2582,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private void EnableSync() { TryApplyQueuedData(); + + if (_pendingModReapply && LastReceivedCharacterData is not null && !IsApplying && CanApplyNow()) + { + ApplyLastReceivedData(forced: true); + } } private void TryApplyQueuedData() diff --git a/LightlessSync/Services/PerformanceCollectorService.cs b/LightlessSync/Services/PerformanceCollectorService.cs index 75fe736..5bec813 100644 --- a/LightlessSync/Services/PerformanceCollectorService.cs +++ b/LightlessSync/Services/PerformanceCollectorService.cs @@ -131,7 +131,10 @@ public sealed class PerformanceCollectorService : IHostedService DrawSeparator(sb, longestCounterName); } - var pastEntries = limitBySeconds > 0 ? entry.Value.Where(e => e.Item1.AddMinutes(limitBySeconds / 60.0d) >= TimeOnly.FromDateTime(DateTime.Now)).ToList() : [.. entry.Value]; + var snapshot = entry.Value.Snapshot(); + var pastEntries = limitBySeconds > 0 + ? snapshot.Where(e => e.Item1.AddMinutes(limitBySeconds / 60.0d) >= TimeOnly.FromDateTime(DateTime.Now)).ToList() + : snapshot; if (pastEntries.Any()) { @@ -189,7 +192,11 @@ public sealed class PerformanceCollectorService : IHostedService { try { - var last = entries.Value.ToList()[^1]; + if (!entries.Value.TryGetLast(out var last)) + { + continue; + } + if (last.Item1.AddMinutes(10) < TimeOnly.FromDateTime(DateTime.Now) && !PerformanceCounters.TryRemove(entries.Key, out _)) { _logger.LogDebug("Could not remove performance counter {counter}", entries.Key); diff --git a/LightlessSync/UI/Components/OptimizationSettingsPanel.cs b/LightlessSync/UI/Components/OptimizationSettingsPanel.cs index a75df2d..7b0477f 100644 --- a/LightlessSync/UI/Components/OptimizationSettingsPanel.cs +++ b/LightlessSync/UI/Components/OptimizationSettingsPanel.cs @@ -108,6 +108,14 @@ public sealed class OptimizationSettingsPanel new SeStringUtils.RichTextEntry("When enabled, we cannot provide support for appearance issues caused by this setting!", UIColors.Get("DimRed"), true)); }); + DrawCallout("texture-opt-info", UIColors.Get("LightlessGrey"), () => + { + _uiSharedService.DrawNoteLine("i ", UIColors.Get("LightlessGrey"), + new SeStringUtils.RichTextEntry("Compression, downscale, and mip trimming only apply to "), + new SeStringUtils.RichTextEntry("newly downloaded pairs", UIColors.Get("LightlessYellow"), true), + new SeStringUtils.RichTextEntry(". Existing downloads are not reprocessed; re-download to apply.")); + }); + ImGui.Dummy(new Vector2(0f, 2f * scale)); DrawGroupHeader("Core Controls", UIColors.Get("LightlessYellow")); @@ -282,6 +290,11 @@ public sealed class OptimizationSettingsPanel new SeStringUtils.RichTextEntry(" will be decimated to the "), new SeStringUtils.RichTextEntry("target ratio", UIColors.Get("LightlessGreen"), true), new SeStringUtils.RichTextEntry(". This can reduce quality or alter intended structure.")); + + _uiSharedService.DrawNoteLine("i ", UIColors.Get("LightlessGreen"), + new SeStringUtils.RichTextEntry("Decimation only applies to "), + new SeStringUtils.RichTextEntry("newly downloaded pairs", UIColors.Get("LightlessYellow"), true), + new SeStringUtils.RichTextEntry(". Existing downloads are not reprocessed; re-download to apply.")); }); DrawGroupHeader("Core Controls", UIColors.Get("LightlessOrange")); diff --git a/LightlessSync/Utils/RollingList.cs b/LightlessSync/Utils/RollingList.cs index 4ddb22b..2528fe8 100644 --- a/LightlessSync/Utils/RollingList.cs +++ b/LightlessSync/Utils/RollingList.cs @@ -29,6 +29,29 @@ public class RollingList : IEnumerable } } + public bool TryGetLast(out T value) + { + lock (_addLock) + { + if (_list.Count == 0) + { + value = default!; + return false; + } + + value = _list.Last!.Value; + return true; + } + } + + public List Snapshot() + { + lock (_addLock) + { + return new List(_list); + } + } + public void Add(T value) { lock (_addLock) -- 2.49.1 From 54d6a0a1a429a196812ad5cbbdf4bcd41f86be47 Mon Sep 17 00:00:00 2001 From: azyges Date: Mon, 19 Jan 2026 09:50:54 +0900 Subject: [PATCH 73/87] reworked mesh decimation yes --- .../Interop/Ipc/IpcCallerPenumbra.cs | 3 + .../Configurations/ModelDecimationSettings.cs | 156 + .../Configurations/PlayerPerformanceConfig.cs | 29 +- .../PlayerData/Pairs/PairHandlerAdapter.cs | 13 +- LightlessSync/Services/CharacterAnalyzer.cs | 28 +- .../Services/ModelDecimation/MdlDecimator.cs | 1980 ++++++++- .../ModelDecimation/ModelDecimationService.cs | 194 +- .../ModelDecimationSettings.cs | 10 + LightlessSync/Services/XivDataAnalyzer.cs | 14 + .../Algorithms/DecimationAlgorithm.cs | 169 - .../FastQuadricMeshSimplification.cs | 1627 ------- .../ThirdParty/MeshDecimator/BoneWeight.cs | 249 -- .../Collections/ResizableArray.cs | 179 - .../MeshDecimator/Collections/UVChannels.cs | 79 - .../ThirdParty/MeshDecimator/LICENSE.md | 21 - .../MeshDecimator/Math/MathHelper.cs | 286 -- .../MeshDecimator/Math/SymmetricMatrix.cs | 303 -- .../ThirdParty/MeshDecimator/Math/Vector2.cs | 425 -- .../ThirdParty/MeshDecimator/Math/Vector2d.cs | 425 -- .../ThirdParty/MeshDecimator/Math/Vector2i.cs | 348 -- .../ThirdParty/MeshDecimator/Math/Vector3.cs | 494 --- .../ThirdParty/MeshDecimator/Math/Vector3d.cs | 481 -- .../ThirdParty/MeshDecimator/Math/Vector3i.cs | 368 -- .../ThirdParty/MeshDecimator/Math/Vector4.cs | 467 -- .../ThirdParty/MeshDecimator/Math/Vector4d.cs | 467 -- .../ThirdParty/MeshDecimator/Math/Vector4i.cs | 388 -- .../ThirdParty/MeshDecimator/Mesh.cs | 1006 ----- .../MeshDecimator/MeshDecimation.cs | 180 - .../Nanomesh/Algo/Decimation/Decimate.cs | 1325 ++++++ .../Nanomesh/Algo/Decimation/EdgeCollapse.cs | 88 + .../Nanomesh/Algo/Decimation/EdgeComparer.cs | 15 + .../Algo/Decimation/SceneDecimator.cs | 72 + .../Nanomesh/Algo/NormalsCreator.cs | 76 + .../ThirdParty/Nanomesh/Algo/NormalsFixer.cs | 17 + .../ThirdParty/Nanomesh/Algo/Triangulate.cs | 27 + .../ThirdParty/Nanomesh/Base/BoneWeight.cs | 144 + .../ThirdParty/Nanomesh/Base/Color32.cs | 110 + .../Nanomesh/Base/FfxivVertexAttribute.cs | 347 ++ .../ThirdParty/Nanomesh/Base/IInterpolable.cs | 7 + .../ThirdParty/Nanomesh/Base/MathF.cs | 356 ++ .../ThirdParty/Nanomesh/Base/MathUtils.cs | 114 + .../ThirdParty/Nanomesh/Base/Profiling.cs | 50 + .../ThirdParty/Nanomesh/Base/Quaternion.cs | 632 +++ .../Nanomesh/Base/SymmetricMatrix.cs | 97 + .../ThirdParty/Nanomesh/Base/TextUtils.cs | 26 + .../ThirdParty/Nanomesh/Base/Vector2.cs | 377 ++ .../ThirdParty/Nanomesh/Base/Vector2F.cs | 371 ++ .../Nanomesh/Base/Vector2FComparer.cs | 28 + .../ThirdParty/Nanomesh/Base/Vector3.cs | 191 + .../Nanomesh/Base/Vector3Comparer.cs | 26 + .../ThirdParty/Nanomesh/Base/Vector3F.cs | 172 + .../Nanomesh/Base/Vector3FComparer.cs | 29 + .../ThirdParty/Nanomesh/Base/Vector4F.cs | 91 + .../Nanomesh/Base/Vector4FComparer.cs | 33 + .../ThirdParty/Nanomesh/Base/VertexData.cs | 48 + .../Nanomesh/Collections/CollectionUtils.cs | 52 + .../Nanomesh/Collections/FastHashSet.cs | 3872 +++++++++++++++++ .../Nanomesh/Collections/LinkedHashSet.cs | 565 +++ .../Nanomesh/Collections/MaxHeap.cs | 86 + .../Nanomesh/Collections/MinHeap.cs | 145 + .../Nanomesh/Collections/OrderStatistics.cs | 118 + .../Mesh/Attributes/AttributeDefinition.cs | 30 + .../Nanomesh/Mesh/Attributes/AttributeType.cs | 39 + .../Nanomesh/Mesh/Attributes/MetaAttribute.cs | 406 ++ .../Mesh/Attributes/MetaAttributeList.cs | 448 ++ .../Mesh/ConnectedMesh/ConnectedMesh.cs | 706 +++ .../Nanomesh/Mesh/ConnectedMesh/Debug.cs | 138 + .../Nanomesh/Mesh/ConnectedMesh/Node.cs | 25 + .../ThirdParty/Nanomesh/Mesh/Group.cs | 8 + .../ThirdParty/Nanomesh/Mesh/SharedMesh.cs | 118 + LightlessSync/ThirdParty/Nanomesh/Todo.md | 22 + .../Components/OptimizationSettingsPanel.cs | 313 +- .../UI/Components/OptimizationSummaryCard.cs | 1 + LightlessSync/UI/DataAnalysisUi.cs | 1746 +++++++- 74 files changed, 15788 insertions(+), 8308 deletions(-) create mode 100644 LightlessSync/LightlessConfiguration/Configurations/ModelDecimationSettings.cs create mode 100644 LightlessSync/Services/ModelDecimation/ModelDecimationSettings.cs delete mode 100644 LightlessSync/ThirdParty/MeshDecimator/Algorithms/DecimationAlgorithm.cs delete mode 100644 LightlessSync/ThirdParty/MeshDecimator/Algorithms/FastQuadricMeshSimplification.cs delete mode 100644 LightlessSync/ThirdParty/MeshDecimator/BoneWeight.cs delete mode 100644 LightlessSync/ThirdParty/MeshDecimator/Collections/ResizableArray.cs delete mode 100644 LightlessSync/ThirdParty/MeshDecimator/Collections/UVChannels.cs delete mode 100644 LightlessSync/ThirdParty/MeshDecimator/LICENSE.md delete mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/MathHelper.cs delete mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/SymmetricMatrix.cs delete mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/Vector2.cs delete mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/Vector2d.cs delete mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/Vector2i.cs delete mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/Vector3.cs delete mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/Vector3d.cs delete mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/Vector3i.cs delete mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/Vector4.cs delete mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/Vector4d.cs delete mode 100644 LightlessSync/ThirdParty/MeshDecimator/Math/Vector4i.cs delete mode 100644 LightlessSync/ThirdParty/MeshDecimator/Mesh.cs delete mode 100644 LightlessSync/ThirdParty/MeshDecimator/MeshDecimation.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/Decimate.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/EdgeCollapse.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/EdgeComparer.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/SceneDecimator.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Algo/NormalsCreator.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Algo/NormalsFixer.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Algo/Triangulate.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Base/BoneWeight.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Base/Color32.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Base/FfxivVertexAttribute.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Base/IInterpolable.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Base/MathF.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Base/MathUtils.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Base/Profiling.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Base/Quaternion.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Base/SymmetricMatrix.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Base/TextUtils.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Base/Vector2.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Base/Vector2F.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Base/Vector2FComparer.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Base/Vector3.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Base/Vector3Comparer.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Base/Vector3F.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Base/Vector3FComparer.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Base/Vector4F.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Base/Vector4FComparer.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Base/VertexData.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Collections/CollectionUtils.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Collections/FastHashSet.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Collections/LinkedHashSet.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Collections/MaxHeap.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Collections/MinHeap.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Collections/OrderStatistics.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Mesh/Attributes/AttributeDefinition.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Mesh/Attributes/AttributeType.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Mesh/Attributes/MetaAttribute.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Mesh/Attributes/MetaAttributeList.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Mesh/ConnectedMesh/ConnectedMesh.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Mesh/ConnectedMesh/Debug.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Mesh/ConnectedMesh/Node.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Mesh/Group.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Mesh/SharedMesh.cs create mode 100644 LightlessSync/ThirdParty/Nanomesh/Todo.md diff --git a/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs b/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs index db63c2a..7ce5aff 100644 --- a/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs +++ b/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs @@ -102,6 +102,9 @@ public sealed class IpcCallerPenumbra : IpcServiceBase public Task RedrawAsync(ILogger logger, GameObjectHandler handler, Guid applicationId, CancellationToken token) => _redraw.RedrawAsync(logger, handler, applicationId, token); + public void RequestImmediateRedraw(int objectIndex, RedrawType redrawType) + => _redraw.RequestImmediateRedraw(objectIndex, redrawType); + public Task ConvertTextureFiles(ILogger logger, IReadOnlyList jobs, IProgress? progress, CancellationToken token, bool requestRedraw = true) => _textures.ConvertTextureFilesAsync(logger, jobs, progress, token, requestRedraw); diff --git a/LightlessSync/LightlessConfiguration/Configurations/ModelDecimationSettings.cs b/LightlessSync/LightlessConfiguration/Configurations/ModelDecimationSettings.cs new file mode 100644 index 0000000..eb910f0 --- /dev/null +++ b/LightlessSync/LightlessConfiguration/Configurations/ModelDecimationSettings.cs @@ -0,0 +1,156 @@ +namespace LightlessSync.LightlessConfiguration.Configurations; + +public static class ModelDecimationDefaults +{ + public const bool EnableAutoDecimation = false; + public const int TriangleThreshold = 15_000; + public const double TargetRatio = 0.8; + public const bool NormalizeTangents = true; + public const bool AvoidBodyIntersection = true; + + /// Default triangle threshold for batch decimation (0 = no threshold). + public const int BatchTriangleThreshold = 0; + + /// Default target triangle ratio for batch decimation. + public const double BatchTargetRatio = 0.8; + + /// Default tangent normalization toggle for batch decimation. + public const bool BatchNormalizeTangents = true; + + /// Default body collision guard toggle for batch decimation. + public const bool BatchAvoidBodyIntersection = true; + + /// Default display for the batch decimation warning overlay. + public const bool ShowBatchDecimationWarning = true; + + public const bool KeepOriginalModelFiles = true; + public const bool SkipPreferredPairs = true; + public const bool AllowBody = false; + public const bool AllowFaceHead = false; + public const bool AllowTail = false; + public const bool AllowClothing = true; + public const bool AllowAccessories = true; +} + +public sealed class ModelDecimationAdvancedSettings +{ + /// Minimum triangles per connected component before skipping decimation. + public const int DefaultMinComponentTriangles = 6; + + /// Average-edge multiplier used to cap collapses. + public const float DefaultMaxCollapseEdgeLengthFactor = 1.25f; + + /// Maximum normal deviation (degrees) allowed for a collapse. + public const float DefaultNormalSimilarityThresholdDegrees = 60f; + + /// Minimum bone-weight overlap required to allow a collapse. + public const float DefaultBoneWeightSimilarityThreshold = 0.85f; + + /// UV similarity threshold to protect seams. + public const float DefaultUvSimilarityThreshold = 0.02f; + + /// UV seam cosine threshold for blocking seam collapses. + public const float DefaultUvSeamAngleCos = 0.99f; + + /// Whether to block UV seam vertices from collapsing. + public const bool DefaultBlockUvSeamVertices = true; + + /// Whether to allow collapses on boundary edges. + public const bool DefaultAllowBoundaryCollapses = false; + + /// Body collision distance factor for the primary pass. + public const float DefaultBodyCollisionDistanceFactor = 0.75f; + + /// Body collision distance factor for the relaxed fallback pass. + public const float DefaultBodyCollisionNoOpDistanceFactor = 0.25f; + + /// Relax multiplier applied when the mesh is close to the body. + public const float DefaultBodyCollisionAdaptiveRelaxFactor = 1.0f; + + /// Ratio of near-body vertices required to trigger relaxation. + public const float DefaultBodyCollisionAdaptiveNearRatio = 0.4f; + + /// UV threshold for relaxed body-collision mode. + public const float DefaultBodyCollisionAdaptiveUvThreshold = 0.08f; + + /// UV seam cosine threshold for relaxed body-collision mode. + public const float DefaultBodyCollisionNoOpUvSeamAngleCos = 0.98f; + + /// Expansion factor for protected vertices near the body. + public const float DefaultBodyCollisionProtectionFactor = 1.5f; + + /// Minimum ratio used when decimating the body proxy. + public const float DefaultBodyProxyTargetRatioMin = 0.85f; + + /// Inflation applied to body collision distances. + public const float DefaultBodyCollisionProxyInflate = 0.0005f; + + /// Body collision penetration factor used during collapse checks. + public const float DefaultBodyCollisionPenetrationFactor = 0.75f; + + /// Minimum body collision distance threshold. + public const float DefaultMinBodyCollisionDistance = 0.0001f; + + /// Minimum cell size for body collision spatial hashing. + public const float DefaultMinBodyCollisionCellSize = 0.0001f; + + /// Minimum triangles per connected component before skipping decimation. + public int MinComponentTriangles { get; set; } = DefaultMinComponentTriangles; + + /// Average-edge multiplier used to cap collapses. + public float MaxCollapseEdgeLengthFactor { get; set; } = DefaultMaxCollapseEdgeLengthFactor; + + /// Maximum normal deviation (degrees) allowed for a collapse. + public float NormalSimilarityThresholdDegrees { get; set; } = DefaultNormalSimilarityThresholdDegrees; + + /// Minimum bone-weight overlap required to allow a collapse. + public float BoneWeightSimilarityThreshold { get; set; } = DefaultBoneWeightSimilarityThreshold; + + /// UV similarity threshold to protect seams. + public float UvSimilarityThreshold { get; set; } = DefaultUvSimilarityThreshold; + + /// UV seam cosine threshold for blocking seam collapses. + public float UvSeamAngleCos { get; set; } = DefaultUvSeamAngleCos; + + /// Whether to block UV seam vertices from collapsing. + public bool BlockUvSeamVertices { get; set; } = DefaultBlockUvSeamVertices; + + /// Whether to allow collapses on boundary edges. + public bool AllowBoundaryCollapses { get; set; } = DefaultAllowBoundaryCollapses; + + /// Body collision distance factor for the primary pass. + public float BodyCollisionDistanceFactor { get; set; } = DefaultBodyCollisionDistanceFactor; + + /// Body collision distance factor for the relaxed fallback pass. + public float BodyCollisionNoOpDistanceFactor { get; set; } = DefaultBodyCollisionNoOpDistanceFactor; + + /// Relax multiplier applied when the mesh is close to the body. + public float BodyCollisionAdaptiveRelaxFactor { get; set; } = DefaultBodyCollisionAdaptiveRelaxFactor; + + /// Ratio of near-body vertices required to trigger relaxation. + public float BodyCollisionAdaptiveNearRatio { get; set; } = DefaultBodyCollisionAdaptiveNearRatio; + + /// UV threshold for relaxed body-collision mode. + public float BodyCollisionAdaptiveUvThreshold { get; set; } = DefaultBodyCollisionAdaptiveUvThreshold; + + /// UV seam cosine threshold for relaxed body-collision mode. + public float BodyCollisionNoOpUvSeamAngleCos { get; set; } = DefaultBodyCollisionNoOpUvSeamAngleCos; + + /// Expansion factor for protected vertices near the body. + public float BodyCollisionProtectionFactor { get; set; } = DefaultBodyCollisionProtectionFactor; + + /// Minimum ratio used when decimating the body proxy. + public float BodyProxyTargetRatioMin { get; set; } = DefaultBodyProxyTargetRatioMin; + + /// Inflation applied to body collision distances. + public float BodyCollisionProxyInflate { get; set; } = DefaultBodyCollisionProxyInflate; + + /// Body collision penetration factor used during collapse checks. + public float BodyCollisionPenetrationFactor { get; set; } = DefaultBodyCollisionPenetrationFactor; + + /// Minimum body collision distance threshold. + public float MinBodyCollisionDistance { get; set; } = DefaultMinBodyCollisionDistance; + + /// Minimum cell size for body collision spatial hashing. + public float MinBodyCollisionCellSize { get; set; } = DefaultMinBodyCollisionCellSize; +} diff --git a/LightlessSync/LightlessConfiguration/Configurations/PlayerPerformanceConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/PlayerPerformanceConfig.cs index 599bea1..98dbc58 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/PlayerPerformanceConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/PlayerPerformanceConfig.cs @@ -25,15 +25,22 @@ public class PlayerPerformanceConfig : ILightlessConfiguration public bool SkipUncompressedTextureCompressionMipMaps { get; set; } = false; public bool KeepOriginalTextureFiles { get; set; } = false; public bool SkipTextureDownscaleForPreferredPairs { get; set; } = true; - public bool EnableModelDecimation { get; set; } = false; - public int ModelDecimationTriangleThreshold { get; set; } = 15_000; - public double ModelDecimationTargetRatio { get; set; } = 0.8; - public bool ModelDecimationNormalizeTangents { get; set; } = true; - public bool KeepOriginalModelFiles { get; set; } = true; - public bool SkipModelDecimationForPreferredPairs { get; set; } = true; - public bool ModelDecimationAllowBody { get; set; } = false; - public bool ModelDecimationAllowFaceHead { get; set; } = false; - public bool ModelDecimationAllowTail { get; set; } = false; - public bool ModelDecimationAllowClothing { get; set; } = true; - public bool ModelDecimationAllowAccessories { get; set; } = true; + public bool EnableModelDecimation { get; set; } = ModelDecimationDefaults.EnableAutoDecimation; + public int ModelDecimationTriangleThreshold { get; set; } = ModelDecimationDefaults.TriangleThreshold; + public double ModelDecimationTargetRatio { get; set; } = ModelDecimationDefaults.TargetRatio; + public bool ModelDecimationNormalizeTangents { get; set; } = ModelDecimationDefaults.NormalizeTangents; + public bool ModelDecimationAvoidBodyIntersection { get; set; } = ModelDecimationDefaults.AvoidBodyIntersection; + public ModelDecimationAdvancedSettings ModelDecimationAdvanced { get; set; } = new(); + public int BatchModelDecimationTriangleThreshold { get; set; } = ModelDecimationDefaults.BatchTriangleThreshold; + public double BatchModelDecimationTargetRatio { get; set; } = ModelDecimationDefaults.BatchTargetRatio; + public bool BatchModelDecimationNormalizeTangents { get; set; } = ModelDecimationDefaults.BatchNormalizeTangents; + public bool BatchModelDecimationAvoidBodyIntersection { get; set; } = ModelDecimationDefaults.BatchAvoidBodyIntersection; + public bool ShowBatchModelDecimationWarning { get; set; } = ModelDecimationDefaults.ShowBatchDecimationWarning; + public bool KeepOriginalModelFiles { get; set; } = ModelDecimationDefaults.KeepOriginalModelFiles; + public bool SkipModelDecimationForPreferredPairs { get; set; } = ModelDecimationDefaults.SkipPreferredPairs; + public bool ModelDecimationAllowBody { get; set; } = ModelDecimationDefaults.AllowBody; + public bool ModelDecimationAllowFaceHead { get; set; } = ModelDecimationDefaults.AllowFaceHead; + public bool ModelDecimationAllowTail { get; set; } = ModelDecimationDefaults.AllowTail; + public bool ModelDecimationAllowClothing { get; set; } = ModelDecimationDefaults.AllowClothing; + public bool ModelDecimationAllowAccessories { get; set; } = ModelDecimationDefaults.AllowAccessories; } \ No newline at end of file diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index 398d96c..ae7733b 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -2404,9 +2404,16 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa continue; } - var preferredPath = skipDownscaleForPair - ? fileCache.ResolvedFilepath - : _textureDownscaleService.GetPreferredPath(item.Hash, fileCache.ResolvedFilepath); + var preferredPath = fileCache.ResolvedFilepath; + if (!skipDownscaleForPair && gamePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)) + { + preferredPath = _textureDownscaleService.GetPreferredPath(item.Hash, preferredPath); + } + + if (!skipDecimationForPair && gamePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase)) + { + preferredPath = _modelDecimationService.GetPreferredPath(item.Hash, preferredPath); + } outputDict[(gamePath, item.Hash)] = preferredPath; } diff --git a/LightlessSync/Services/CharacterAnalyzer.cs b/LightlessSync/Services/CharacterAnalyzer.cs index 58388ae..34bb47d 100644 --- a/LightlessSync/Services/CharacterAnalyzer.cs +++ b/LightlessSync/Services/CharacterAnalyzer.cs @@ -106,7 +106,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable _baseAnalysisCts.Dispose(); } - public async Task UpdateFileEntriesAsync(IEnumerable filePaths, CancellationToken token) + public async Task UpdateFileEntriesAsync(IEnumerable filePaths, CancellationToken token, bool force = false) { var normalized = new HashSet( filePaths.Where(path => !string.IsNullOrWhiteSpace(path)), @@ -115,6 +115,8 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable { return; } + + var updated = false; foreach (var objectEntries in LastAnalysis.Values) { foreach (var entry in objectEntries.Values) @@ -124,9 +126,26 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable continue; } token.ThrowIfCancellationRequested(); - await entry.ComputeSizes(_fileCacheManager, token).ConfigureAwait(false); + await entry.ComputeSizes(_fileCacheManager, token, force).ConfigureAwait(false); + + if (string.Equals(entry.FileType, "mdl", StringComparison.OrdinalIgnoreCase)) + { + var sourcePath = entry.FilePaths.FirstOrDefault(path => !string.IsNullOrWhiteSpace(path)); + if (!string.IsNullOrWhiteSpace(sourcePath)) + { + entry.UpdateTriangles(_xivDataAnalyzer.RefreshTrianglesForPath(entry.Hash, sourcePath)); + } + } + + updated = true; } } + + if (updated) + { + RecalculateSummary(); + Mediator.Publish(new CharacterDataAnalyzedMessage()); + } } private async Task BaseAnalysis(CharacterData charaData, CancellationToken token) @@ -311,6 +330,10 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable var original = new FileInfo(path).Length; var compressedLen = await fileCacheManager.GetCompressedSizeAsync(Hash, token).ConfigureAwait(false); + if (compressedLen <= 0 && !string.Equals(FileType, "tex", StringComparison.OrdinalIgnoreCase)) + { + compressedLen = original; + } fileCacheManager.SetSizeInfo(Hash, original, compressedLen); FileCacheManager.ApplySizesToEntries(CacheEntries, original, compressedLen); @@ -326,6 +349,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable private Lazy? _format; public void RefreshFormat() => _format = CreateFormatValue(); + public void UpdateTriangles(long triangles) => Triangles = triangles; private Lazy CreateFormatValue() => new(() => diff --git a/LightlessSync/Services/ModelDecimation/MdlDecimator.cs b/LightlessSync/Services/ModelDecimation/MdlDecimator.cs index c47f3f4..cbecf68 100644 --- a/LightlessSync/Services/ModelDecimation/MdlDecimator.cs +++ b/LightlessSync/Services/ModelDecimation/MdlDecimator.cs @@ -1,19 +1,24 @@ +using LightlessSync.LightlessConfiguration.Configurations; using Lumina.Data.Parsing; using Lumina.Extensions; -using MeshDecimator; -using MeshDecimator.Algorithms; -using MeshDecimator.Math; using Microsoft.Extensions.Logging; +using Nano = Nanomesh; using Penumbra.GameData.Files.ModelStructs; using System.Buffers.Binary; +using BoneWeight = Nanomesh.BoneWeight; using MdlFile = Penumbra.GameData.Files.MdlFile; using MsLogger = Microsoft.Extensions.Logging.ILogger; +using Vector2 = Nanomesh.Vector2F; +using Vector3 = Nanomesh.Vector3F; +using Vector3d = Nanomesh.Vector3; +using Vector4 = Nanomesh.Vector4F; namespace LightlessSync.Services.ModelDecimation; // if you're coming from another sync service, then kindly fuck off. lightless ftw lil bro internal static class MdlDecimator { private const int MaxStreams = 3; + private const int MaxUvChannels = 4; private const int ReadRetryCount = 8; private const int ReadRetryDelayMs = 250; @@ -47,10 +52,11 @@ internal static class MdlDecimator MdlFile.VertexType.UShort4, ]; - public static bool TryDecimate(string sourcePath, string destinationPath, int triangleThreshold, double targetRatio, bool normalizeTangents, MsLogger logger) + public static bool TryDecimate(string sourcePath, string destinationPath, ModelDecimationSettings settings, MsLogger logger) { try { + var tuning = settings.Advanced; if (!TryReadModelBytes(sourcePath, logger, out var data)) { logger.LogInformation("Skipping model decimation; source file locked or unreadable: {Path}", sourcePath); @@ -98,13 +104,30 @@ internal static class MdlDecimator return false; } + Dictionary bodyMeshOverrides = []; + BodyCollisionData? bodyCollision = null; + if (settings.AvoidBodyIntersection) + { + if (!TryBuildBodyCollisionData( + mdl, + lodIndex, + lodMeshStart, + lodMeshEnd, + settings, + tuning, + out bodyCollision, + out bodyMeshOverrides, + logger)) + { + bodyCollision = null; + } + } + var anyDecimated = false; var newSubMeshes = new List(mdl.SubMeshes.Length); var newVertexBuffer = new List(mdl.VertexBufferSize[lodIndex] > 0 ? (int)mdl.VertexBufferSize[lodIndex] : 0); var newIndexBuffer = new List(mdl.IndexBufferSize[lodIndex] > 0 ? (int)(mdl.IndexBufferSize[lodIndex] / sizeof(ushort)) : 0); var subMeshCursor = 0; - DecimationAlgorithm? decimationAlgorithm = null; - int? decimationUvChannelCount = null; for (var meshIndex = 0; meshIndex < meshes.Length; meshIndex++) { @@ -123,15 +146,22 @@ internal static class MdlDecimator int[] indices; bool decimated; - if (meshIndex >= lodMeshStart && meshIndex < lodMeshEnd - && TryProcessMesh(mdl, lodIndex, meshIndex, mesh, meshSubMeshes, triangleThreshold, targetRatio, normalizeTangents, + if (bodyMeshOverrides.TryGetValue(meshIndex, out var bodyOverride)) + { + updatedMesh = bodyOverride.Mesh; + updatedSubMeshes = bodyOverride.SubMeshes; + vertexStreams = bodyOverride.VertexStreams; + indices = bodyOverride.Indices; + decimated = bodyOverride.Decimated; + updatedSubMeshes = OffsetSubMeshes(updatedSubMeshes, meshIndexBase); + } + else if (meshIndex >= lodMeshStart && meshIndex < lodMeshEnd + && TryProcessMesh(mdl, lodIndex, meshIndex, mesh, meshSubMeshes, settings, tuning, bodyCollision, out updatedMesh, out updatedSubMeshes, out vertexStreams, out indices, out decimated, - ref decimationAlgorithm, - ref decimationUvChannelCount, logger)) { updatedSubMeshes = OffsetSubMeshes(updatedSubMeshes, meshIndexBase); @@ -315,16 +345,14 @@ internal static class MdlDecimator int meshIndex, MeshStruct mesh, MdlStructs.SubmeshStruct[] meshSubMeshes, - int triangleThreshold, - double targetRatio, - bool normalizeTangents, + ModelDecimationSettings settings, + ModelDecimationAdvancedSettings tuning, + BodyCollisionData? bodyCollision, out MeshStruct updatedMesh, out MdlStructs.SubmeshStruct[] updatedSubMeshes, out byte[][] vertexStreams, out int[] indices, out bool decimated, - ref DecimationAlgorithm? decimationAlgorithm, - ref int? decimationUvChannelCount, MsLogger logger) { updatedMesh = mesh; @@ -344,7 +372,7 @@ internal static class MdlDecimator } var triangleCount = (int)(mesh.IndexCount / 3); - if (triangleCount < triangleThreshold) + if (triangleCount < settings.TriangleThreshold) { return false; } @@ -361,25 +389,66 @@ internal static class MdlDecimator return false; } - var targetTriangles = (int)Math.Floor(triangleCount * targetRatio); + var targetTriangles = (int)Math.Floor(triangleCount * settings.TargetRatio); if (targetTriangles < 1 || targetTriangles >= triangleCount) { + logger.LogDebug( + "Mesh {MeshIndex} decimation target invalid ({Target} vs {Triangles})", + meshIndex, + targetTriangles, + triangleCount); return false; } - var meshDecimatorMesh = BuildMesh(decoded, subMeshIndices); - var algorithm = GetOrCreateAlgorithm(format, ref decimationAlgorithm, ref decimationUvChannelCount, logger); - algorithm.Initialize(meshDecimatorMesh); - algorithm.DecimateMesh(targetTriangles); - var decimatedMesh = algorithm.ToMesh(); + var collisionData = bodyCollision; + if (collisionData != null && IsBodyMesh(mdl, mesh)) + { + collisionData = null; + } - if (decimatedMesh.SubMeshCount != meshSubMeshes.Length) + if (!TryDecimateWithNanomesh(decoded, subMeshIndices, format, targetTriangles, tuning, collisionData, out var decimatedData, out var decimatedSubMeshIndices, out var decimationStats, out var decimationReason)) + { + logger.LogDebug("Mesh {MeshIndex} decimation failed: {Reason}", meshIndex, decimationReason); + return false; + } + + if (decimatedSubMeshIndices.Length != meshSubMeshes.Length) { logger.LogDebug("Mesh {MeshIndex} submesh count changed after decimation", meshIndex); return false; } - if (!TryEncodeMeshData(decimatedMesh, format, mesh, meshSubMeshes, normalizeTangents, out updatedMesh, out updatedSubMeshes, out vertexStreams, out indices, out var encodeReason)) + var decimatedTriangles = 0; + for (var subMeshIndex = 0; subMeshIndex < decimatedSubMeshIndices.Length; subMeshIndex++) + { + decimatedTriangles += decimatedSubMeshIndices[subMeshIndex].Length / 3; + } + + if (decimatedTriangles <= 0 || decimatedTriangles >= triangleCount) + { + logger.LogInformation( + "Mesh {MeshIndex} decimation produced no reduction (before {Before}, after {After}, components {Components}, eligible {Eligible}, min {Min}, max {Max}, avg {Avg:0.##}, eval {Evaluated}, collapsed {Collapsed}, reject bone {RejectBone}, body {RejectBody}, topo {RejectTopo}, invert {RejectInvert} (deg {RejectDeg}, area {RejectArea}, flip {RejectFlip})", + meshIndex, + triangleCount, + decimatedTriangles, + decimationStats.TotalComponents, + decimationStats.EligibleComponents, + decimationStats.MinTriangles, + decimationStats.MaxTriangles, + decimationStats.AvgTriangles, + decimationStats.EvaluatedEdges, + decimationStats.CollapsedEdges, + decimationStats.RejectedBoneWeights, + decimationStats.RejectedBodyCollision, + decimationStats.RejectedTopology, + decimationStats.RejectedInversion, + decimationStats.RejectedDegenerate, + decimationStats.RejectedArea, + decimationStats.RejectedFlip); + return false; + } + + if (!TryEncodeMeshData(decimatedData, decimatedSubMeshIndices, format, mesh, meshSubMeshes, settings.NormalizeTangents, out updatedMesh, out updatedSubMeshes, out vertexStreams, out indices, out var encodeReason)) { logger.LogDebug("Mesh {MeshIndex} encode failed: {Reason}", meshIndex, encodeReason); return false; @@ -389,70 +458,1534 @@ internal static class MdlDecimator return true; } - private static DecimationAlgorithm GetOrCreateAlgorithm( + private static bool TryDecimateWithNanomesh( + DecodedMeshData decoded, + int[][] subMeshIndices, VertexFormat format, - ref DecimationAlgorithm? decimationAlgorithm, - ref int? decimationUvChannelCount, - MsLogger logger) + int targetTriangles, + ModelDecimationAdvancedSettings tuning, + BodyCollisionData? bodyCollision, + out DecodedMeshData decimated, + out int[][] decimatedSubMeshIndices, + out ComponentStats componentStats, + out string? reason) { - var uvChannelCount = format.UvChannelCount; - if (decimationAlgorithm == null || decimationUvChannelCount != uvChannelCount) + decimated = default!; + decimatedSubMeshIndices = []; + componentStats = default; + reason = null; + + var totalTriangles = 0; + for (var i = 0; i < subMeshIndices.Length; i++) { - decimationAlgorithm = MeshDecimation.CreateAlgorithm(Algorithm.Default); - decimationAlgorithm.Logger = logger; - decimationUvChannelCount = uvChannelCount; + totalTriangles += subMeshIndices[i].Length / 3; } - return decimationAlgorithm; - } - - private static Mesh BuildMesh(DecodedMeshData decoded, int[][] subMeshIndices) - { - var mesh = new Mesh(decoded.Positions, subMeshIndices); - if (decoded.Normals != null) + if (totalTriangles <= 0) { - mesh.Normals = decoded.Normals; + reason = "No triangles to decimate."; + return false; } - if (decoded.PositionWs != null) + var targetRatio = Math.Clamp(targetTriangles / (float)totalTriangles, 0f, 1f); + var outputSubMeshes = new List[subMeshIndices.Length]; + for (var i = 0; i < outputSubMeshes.Length; i++) { - mesh.PositionWs = decoded.PositionWs; + outputSubMeshes[i] = new List(); } - if (decoded.NormalWs != null) + var positions = new List(); + var normals = format.HasNormals ? new List() : null; + var tangents = format.HasTangent1 ? new List() : null; + var tangents2 = format.HasTangent2 ? new List() : null; + var colors = format.HasColors ? new List() : null; + var boneWeights = format.HasSkinning ? new List() : null; + var positionWs = format.HasPositionW ? new List() : null; + var normalWs = format.HasNormalW ? new List() : null; + List[]? uvChannels = null; + if (format.UvChannelCount > 0) { - mesh.NormalWs = decoded.NormalWs; - } - - if (decoded.Tangents != null) - { - mesh.Tangents = decoded.Tangents; - } - - if (decoded.Tangents2 != null) - { - mesh.Tangents2 = decoded.Tangents2; - } - - if (decoded.Colors != null) - { - mesh.Colors = decoded.Colors; - } - - if (decoded.BoneWeights != null) - { - mesh.BoneWeights = decoded.BoneWeights; - } - - if (decoded.UvChannels != null) - { - for (var channel = 0; channel < decoded.UvChannels.Length; channel++) + uvChannels = new List[format.UvChannelCount]; + for (var channel = 0; channel < format.UvChannelCount; channel++) { - mesh.SetUVs(channel, decoded.UvChannels[channel]); + uvChannels[channel] = new List(); } } - return mesh; + var componentCount = 0; + var eligibleCount = 0; + var minComponentTriangles = int.MaxValue; + var maxComponentTriangles = 0; + var totalComponentTriangles = 0; + var evaluatedEdges = 0; + var collapsedEdges = 0; + var rejectedBoneWeights = 0; + var rejectedTopology = 0; + var rejectedInversion = 0; + var rejectedDegenerate = 0; + var rejectedArea = 0; + var rejectedFlip = 0; + var rejectedBodyCollision = 0; + + for (var subMeshIndex = 0; subMeshIndex < subMeshIndices.Length; subMeshIndex++) + { + var indices = subMeshIndices[subMeshIndex]; + if (indices.Length == 0) + { + continue; + } + + var components = BuildComponentsForSubMesh(indices); + foreach (var componentIndices in components) + { + if (componentIndices.Length == 0) + { + continue; + } + + var componentTriangles = componentIndices.Length / 3; + if (componentTriangles == 0) + { + continue; + } + + var componentTarget = ComputeComponentTarget(componentTriangles, targetRatio, tuning.MinComponentTriangles); + componentCount++; + totalComponentTriangles += componentTriangles; + minComponentTriangles = Math.Min(minComponentTriangles, componentTriangles); + maxComponentTriangles = Math.Max(maxComponentTriangles, componentTriangles); + if (componentTarget < componentTriangles) + { + eligibleCount++; + } + + if (!TryBuildComponentDecoded(decoded, format, componentIndices, out var componentDecoded, out var componentLocalIndices, out reason)) + { + return false; + } + + DecodedMeshData componentDecimated = componentDecoded; + var componentDecimatedIndices = componentLocalIndices; + + if (componentTarget < componentTriangles) + { + if (TryDecimateComponent(componentDecoded, format, componentLocalIndices, componentTarget, decoded.BlendWeightEncoding, tuning, bodyCollision, out var decimatedComponent, out var decimatedComponentIndices, out var decimatorStats, out _)) + { + componentDecimated = decimatedComponent; + componentDecimatedIndices = decimatedComponentIndices; + evaluatedEdges += decimatorStats.EvaluatedEdges; + collapsedEdges += decimatorStats.CollapsedEdges; + rejectedBoneWeights += decimatorStats.RejectedBoneWeights; + rejectedTopology += decimatorStats.RejectedTopology; + rejectedInversion += decimatorStats.RejectedInversion; + rejectedDegenerate += decimatorStats.RejectedDegenerate; + rejectedArea += decimatorStats.RejectedArea; + rejectedFlip += decimatorStats.RejectedFlip; + rejectedBodyCollision += decimatorStats.RejectedBodyCollision; + } + } + + if (!AppendComponentData( + componentDecimated, + componentDecimatedIndices, + format, + positions, + normals, + tangents, + tangents2, + colors, + boneWeights, + uvChannels, + positionWs, + normalWs, + outputSubMeshes[subMeshIndex], + out reason)) + { + return false; + } + } + } + + if (positions.Count > ushort.MaxValue) + { + reason = "Decimated mesh exceeds vertex limit."; + return false; + } + + componentStats = BuildComponentStats( + componentCount, + eligibleCount, + minComponentTriangles, + maxComponentTriangles, + totalComponentTriangles, + evaluatedEdges, + collapsedEdges, + rejectedBoneWeights, + rejectedTopology, + rejectedInversion, + rejectedDegenerate, + rejectedArea, + rejectedFlip, + rejectedBodyCollision); + + decimated = new DecodedMeshData( + positions.ToArray(), + normals?.ToArray(), + tangents?.ToArray(), + tangents2?.ToArray(), + colors?.ToArray(), + boneWeights?.ToArray(), + uvChannels?.Select(channel => channel.ToArray()).ToArray(), + positionWs?.ToArray(), + normalWs?.ToArray(), + decoded.BlendWeightEncoding); + + decimatedSubMeshIndices = outputSubMeshes.Select(list => list.ToArray()).ToArray(); + return true; + } + + private static ComponentStats BuildComponentStats( + int componentCount, + int eligibleCount, + int minTriangles, + int maxTriangles, + int totalTriangles, + int evaluatedEdges, + int collapsedEdges, + int rejectedBoneWeights, + int rejectedTopology, + int rejectedInversion, + int rejectedDegenerate, + int rejectedArea, + int rejectedFlip, + int rejectedBodyCollision) + { + if (componentCount <= 0) + { + return new ComponentStats(0, 0, 0, 0, 0d, 0, 0, 0, 0, 0, 0, 0, 0, 0); + } + + var average = totalTriangles / (double)componentCount; + return new ComponentStats( + componentCount, + eligibleCount, + minTriangles == int.MaxValue ? 0 : minTriangles, + maxTriangles, + average, + evaluatedEdges, + collapsedEdges, + rejectedBoneWeights, + rejectedTopology, + rejectedInversion, + rejectedDegenerate, + rejectedArea, + rejectedFlip, + rejectedBodyCollision); + } + + private readonly record struct ComponentStats( + int TotalComponents, + int EligibleComponents, + int MinTriangles, + int MaxTriangles, + double AvgTriangles, + int EvaluatedEdges, + int CollapsedEdges, + int RejectedBoneWeights, + int RejectedTopology, + int RejectedInversion, + int RejectedDegenerate, + int RejectedArea, + int RejectedFlip, + int RejectedBodyCollision); + + private static int ComputeComponentTarget(int componentTriangles, float targetRatio, int minComponentTriangles) + { + var minTriangles = Math.Max(1, minComponentTriangles); + if (componentTriangles <= minTriangles) + { + return componentTriangles; + } + + var target = (int)MathF.Round(componentTriangles * targetRatio); + target = Math.Max(1, target); + return Math.Min(componentTriangles, Math.Max(minTriangles, target)); + } + + private static List BuildComponentsForSubMesh(int[] indices) + { + var components = new List(); + if (indices.Length == 0) + { + return components; + } + + var triangleCount = indices.Length / 3; + if (triangleCount <= 1) + { + components.Add(indices); + return components; + } + + var parent = new int[triangleCount]; + var rank = new byte[triangleCount]; + for (var i = 0; i < triangleCount; i++) + { + parent[i] = i; + } + + var vertexToTriangle = new Dictionary(); + for (var tri = 0; tri < triangleCount; tri++) + { + var baseIndex = tri * 3; + for (var v = 0; v < 3; v++) + { + var vertexIndex = indices[baseIndex + v]; + if (vertexToTriangle.TryGetValue(vertexIndex, out var existing)) + { + Union(parent, rank, tri, existing); + } + else + { + vertexToTriangle[vertexIndex] = tri; + } + } + } + + var componentMap = new Dictionary>(); + for (var tri = 0; tri < triangleCount; tri++) + { + var root = Find(parent, tri); + if (!componentMap.TryGetValue(root, out var list)) + { + list = []; + componentMap[root] = list; + } + + list.Add(tri); + } + + foreach (var component in componentMap.Values) + { + var slice = new int[component.Count * 3]; + var cursor = 0; + foreach (var tri in component) + { + Array.Copy(indices, tri * 3, slice, cursor, 3); + cursor += 3; + } + + components.Add(slice); + } + + return components; + } + + private static bool TryBuildComponentDecoded( + DecodedMeshData decoded, + VertexFormat format, + int[] componentIndices, + out DecodedMeshData componentDecoded, + out int[] componentLocalIndices, + out string? reason) + { + componentDecoded = default!; + componentLocalIndices = []; + reason = null; + + if (componentIndices.Length == 0) + { + reason = "Component has no indices."; + return false; + } + + var vertexMap = new Dictionary(); + var positions = new List(); + var normals = format.HasNormals ? new List() : null; + var tangents = format.HasTangent1 ? new List() : null; + var tangents2 = format.HasTangent2 ? new List() : null; + var colors = format.HasColors ? new List() : null; + var boneWeights = format.HasSkinning ? new List() : null; + var positionWs = format.HasPositionW ? new List() : null; + var normalWs = format.HasNormalW ? new List() : null; + List[]? uvChannels = null; + if (format.UvChannelCount > 0) + { + uvChannels = new List[format.UvChannelCount]; + for (var channel = 0; channel < format.UvChannelCount; channel++) + { + uvChannels[channel] = new List(); + } + } + + componentLocalIndices = new int[componentIndices.Length]; + for (var i = 0; i < componentIndices.Length; i++) + { + var globalIndex = componentIndices[i]; + if (globalIndex < 0 || globalIndex >= decoded.Positions.Length) + { + reason = "Component vertex index out of bounds."; + return false; + } + + if (!vertexMap.TryGetValue(globalIndex, out var localIndex)) + { + localIndex = positions.Count; + vertexMap[globalIndex] = localIndex; + positions.Add(decoded.Positions[globalIndex]); + + if (normals != null) + { + normals.Add(decoded.Normals != null ? decoded.Normals[globalIndex] : default); + } + if (tangents != null) + { + tangents.Add(decoded.Tangents != null ? decoded.Tangents[globalIndex] : default); + } + if (tangents2 != null) + { + tangents2.Add(decoded.Tangents2 != null ? decoded.Tangents2[globalIndex] : default); + } + if (colors != null) + { + colors.Add(decoded.Colors != null ? decoded.Colors[globalIndex] : default); + } + if (boneWeights != null) + { + boneWeights.Add(decoded.BoneWeights != null ? decoded.BoneWeights[globalIndex] : default); + } + if (positionWs != null) + { + positionWs.Add(decoded.PositionWs != null ? decoded.PositionWs[globalIndex] : 0f); + } + if (normalWs != null) + { + normalWs.Add(decoded.NormalWs != null ? decoded.NormalWs[globalIndex] : 0f); + } + if (uvChannels != null) + { + for (var channel = 0; channel < uvChannels.Length; channel++) + { + var source = decoded.UvChannels != null && channel < decoded.UvChannels.Length + ? decoded.UvChannels[channel] + : null; + uvChannels[channel].Add(source != null ? source[globalIndex] : default); + } + } + } + + componentLocalIndices[i] = localIndex; + } + + componentDecoded = new DecodedMeshData( + positions.ToArray(), + normals?.ToArray(), + tangents?.ToArray(), + tangents2?.ToArray(), + colors?.ToArray(), + boneWeights?.ToArray(), + uvChannels?.Select(channel => channel.ToArray()).ToArray(), + positionWs?.ToArray(), + normalWs?.ToArray(), + decoded.BlendWeightEncoding); + + return true; + } + + private static bool TryDecimateComponent( + DecodedMeshData componentDecoded, + VertexFormat format, + int[] componentIndices, + int targetTriangles, + BlendWeightEncoding blendWeightEncoding, + ModelDecimationAdvancedSettings tuning, + BodyCollisionData? bodyCollision, + out DecodedMeshData decimated, + out int[] decimatedIndices, + out Nano.DecimationStats decimatorStats, + out string? reason) + { + decimated = default!; + decimatedIndices = []; + decimatorStats = default; + reason = null; + + var componentTriangles = componentIndices.Length / 3; + var avgEdgeLength = ComputeAverageEdgeLength(componentDecoded.Positions, componentIndices); + bool RunDecimation( + float bodyCollisionDistanceFactor, + bool allowProtectedVertices, + bool expandProtectedVertices, + bool allowProtectedVerticesWhenRelaxed, + bool forceRelaxTopology, + bool blockUvSeamVertices, + float? uvSeamAngleCosOverride, + out DecodedMeshData runDecimated, + out int[] runDecimatedIndices, + out Nano.DecimationStats runDecimatorStats, + out string? runReason) + { + runDecimated = default!; + runDecimatedIndices = []; + runDecimatorStats = default; + runReason = null; + + if (!TryBuildNanomeshMesh(componentDecoded, [componentIndices], format, out var sharedMesh, out runReason)) + { + return false; + } + + if (avgEdgeLength > 0f && tuning.MaxCollapseEdgeLengthFactor > 0f) + { + Nano.DecimateModifier.LimitCollapseEdgeLength = true; + Nano.DecimateModifier.MaxCollapseEdgeLength = avgEdgeLength * tuning.MaxCollapseEdgeLengthFactor; + } + else + { + Nano.DecimateModifier.LimitCollapseEdgeLength = false; + Nano.DecimateModifier.MaxCollapseEdgeLength = float.PositiveInfinity; + } + + var relaxTopology = forceRelaxTopology; + var decimator = new Nano.DecimateModifier(); + if (bodyCollision != null) + { + var threshold = MathF.Max(avgEdgeLength * bodyCollisionDistanceFactor + tuning.BodyCollisionProxyInflate, tuning.MinBodyCollisionDistance); + var bodyDistanceSq = bodyCollision.ComputeDistanceSq(componentDecoded.Positions, threshold); + if (bodyDistanceSq != null) + { + var thresholdSq = threshold * threshold; + var protectionThreshold = MathF.Max(threshold * tuning.BodyCollisionProtectionFactor, threshold); + var protectionThresholdSq = protectionThreshold * protectionThreshold; + var protectedDistanceSq = allowProtectedVertices + ? bodyCollision.ComputeDistanceSq(componentDecoded.Positions, protectionThreshold) + : null; + var relaxedBodyGuard = forceRelaxTopology; + if (!forceRelaxTopology && IsNearBodyDominant(bodyDistanceSq, thresholdSq, componentDecoded.Positions.Length, tuning.BodyCollisionAdaptiveNearRatio)) + { + threshold = MathF.Max(threshold * tuning.BodyCollisionAdaptiveRelaxFactor, tuning.MinBodyCollisionDistance); + thresholdSq = threshold * threshold; + relaxedBodyGuard = true; + relaxTopology = true; + + protectionThreshold = MathF.Max(threshold * tuning.BodyCollisionProtectionFactor, threshold); + protectionThresholdSq = protectionThreshold * protectionThreshold; + if (allowProtectedVertices) + { + protectedDistanceSq = bodyCollision.ComputeDistanceSq(componentDecoded.Positions, protectionThreshold); + } + } + + decimator.SetBodyCollision(bodyDistanceSq, thresholdSq, point => bodyCollision.DistanceSq(point, thresholdSq)); + if (allowProtectedVertices && (!relaxedBodyGuard || allowProtectedVerticesWhenRelaxed)) + { + var useExpandedProtection = expandProtectedVertices && !relaxTopology; + var protectedVertices = protectedDistanceSq != null + ? BuildProtectedVertices(componentDecoded.Positions.Length, componentIndices, protectedDistanceSq, protectionThresholdSq, useExpandedProtection) + : null; + if (protectedVertices != null) + { + decimator.SetProtectedVertices(protectedVertices); + } + } + } + } + + if (relaxTopology) + { + sharedMesh.attributeDefinitions = [new Nano.AttributeDefinition(Nano.AttributeType.Normals, 0d, 0)]; + } + + var connectedMesh = sharedMesh.ToConnectedMesh(); + Nano.DecimateModifier.CollapseToEndpointsOnly = true; + var previousNormalSimilarity = Nano.DecimateModifier.NormalSimilarityThresholdDegrees; + var previousBoneWeightSimilarity = Nano.DecimateModifier.BoneWeightSimilarityThreshold; + var previousBodyPenetration = Nano.DecimateModifier.BodyCollisionPenetrationFactor; + var previousUvThreshold = Nano.DecimateModifier.UvSimilarityThreshold; + var previousAllowBoundary = Nano.DecimateModifier.AllowBoundaryCollapses; + var previousBlockUvSeamVertices = Nano.DecimateModifier.BlockUvSeamVertices; + var previousUvSeamAngleCos = Nano.DecimateModifier.UvSeamAngleCos; + try + { + Nano.DecimateModifier.NormalSimilarityThresholdDegrees = tuning.NormalSimilarityThresholdDegrees; + Nano.DecimateModifier.BoneWeightSimilarityThreshold = tuning.BoneWeightSimilarityThreshold; + Nano.DecimateModifier.BodyCollisionPenetrationFactor = tuning.BodyCollisionPenetrationFactor; + Nano.DecimateModifier.UvSimilarityThreshold = tuning.UvSimilarityThreshold; + Nano.DecimateModifier.AllowBoundaryCollapses = tuning.AllowBoundaryCollapses; + Nano.DecimateModifier.BlockUvSeamVertices = blockUvSeamVertices && tuning.BlockUvSeamVertices; + Nano.DecimateModifier.UvSeamAngleCos = tuning.UvSeamAngleCos; + + if (relaxTopology) + { + Nano.DecimateModifier.UvSimilarityThreshold = tuning.BodyCollisionAdaptiveUvThreshold; + Nano.DecimateModifier.AllowBoundaryCollapses = false; + } + + if (uvSeamAngleCosOverride.HasValue) + { + Nano.DecimateModifier.UvSeamAngleCos = uvSeamAngleCosOverride.Value; + } + + decimator.Initialize(connectedMesh); + decimator.DecimateToPolycount(targetTriangles); + runDecimatorStats = decimator.GetStats(); + } + finally + { + Nano.DecimateModifier.NormalSimilarityThresholdDegrees = previousNormalSimilarity; + Nano.DecimateModifier.BoneWeightSimilarityThreshold = previousBoneWeightSimilarity; + Nano.DecimateModifier.BodyCollisionPenetrationFactor = previousBodyPenetration; + Nano.DecimateModifier.UvSimilarityThreshold = previousUvThreshold; + Nano.DecimateModifier.AllowBoundaryCollapses = previousAllowBoundary; + Nano.DecimateModifier.BlockUvSeamVertices = previousBlockUvSeamVertices; + Nano.DecimateModifier.UvSeamAngleCos = previousUvSeamAngleCos; + } + + var decimatedShared = connectedMesh.ToSharedMesh(); + if (!TryConvertNanomeshMesh(decimatedShared, format, 1, blendWeightEncoding, out runDecimated, out var subMeshes, out runReason)) + { + return false; + } + + if (subMeshes.Length > 0) + { + runDecimatedIndices = subMeshes[0]; + } + + return true; + } + + if (!RunDecimation( + tuning.BodyCollisionDistanceFactor, + allowProtectedVertices: true, + expandProtectedVertices: true, + allowProtectedVerticesWhenRelaxed: true, + forceRelaxTopology: false, + blockUvSeamVertices: true, + uvSeamAngleCosOverride: null, + out decimated, + out decimatedIndices, + out decimatorStats, + out reason)) + { + return false; + } + + if (decimatorStats.CollapsedEdges == 0 && targetTriangles < componentTriangles && bodyCollision != null) + { + if (RunDecimation( + tuning.BodyCollisionNoOpDistanceFactor, + allowProtectedVertices: true, + expandProtectedVertices: false, + allowProtectedVerticesWhenRelaxed: true, + forceRelaxTopology: true, + blockUvSeamVertices: false, + uvSeamAngleCosOverride: tuning.BodyCollisionNoOpUvSeamAngleCos, + out var fallbackDecimated, + out var fallbackDecimatedIndices, + out var fallbackStats, + out _)) + { + var fallbackTriangles = fallbackDecimatedIndices.Length / 3; + if (fallbackStats.CollapsedEdges > 0 && fallbackTriangles > 0 && fallbackTriangles < componentTriangles) + { + decimated = fallbackDecimated; + decimatedIndices = fallbackDecimatedIndices; + decimatorStats = fallbackStats; + } + } + } + + return true; + } + + private static float ComputeAverageEdgeLength(Vector3d[] positions, int[] indices) + { + if (positions.Length == 0 || indices.Length < 3) + { + return 0f; + } + + double sum = 0d; + int count = 0; + for (var i = 0; i + 2 < indices.Length; i += 3) + { + var i0 = indices[i]; + var i1 = indices[i + 1]; + var i2 = indices[i + 2]; + if ((uint)i0 >= positions.Length || (uint)i1 >= positions.Length || (uint)i2 >= positions.Length) + { + continue; + } + + sum += Vector3d.Distance(positions[i0], positions[i1]); + sum += Vector3d.Distance(positions[i1], positions[i2]); + sum += Vector3d.Distance(positions[i2], positions[i0]); + count += 3; + } + + return count > 0 ? (float)(sum / count) : 0f; + } + + private static bool[]? BuildProtectedVertices(int vertexCount, int[] indices, float[] distanceSq, float thresholdSq, bool expand) + { + if (vertexCount <= 0 || distanceSq.Length == 0) + { + return null; + } + + var seed = new bool[vertexCount]; + var seedCount = 0; + var limit = Math.Min(vertexCount, distanceSq.Length); + for (var i = 0; i < limit; i++) + { + if (distanceSq[i] <= thresholdSq) + { + seed[i] = true; + seedCount++; + } + } + + if (seedCount == 0) + { + return null; + } + + if (!expand || indices.Length < 3) + { + return seed; + } + + var expanded = (bool[])seed.Clone(); + for (var i = 0; i + 2 < indices.Length; i += 3) + { + var a = indices[i]; + var b = indices[i + 1]; + var c = indices[i + 2]; + if ((uint)a >= vertexCount || (uint)b >= vertexCount || (uint)c >= vertexCount) + { + continue; + } + + if (seed[a] || seed[b] || seed[c]) + { + expanded[a] = true; + expanded[b] = true; + expanded[c] = true; + } + } + + return expanded; + } + + private static bool IsNearBodyDominant(float[] distanceSq, float thresholdSq, int vertexCount, float adaptiveNearRatio) + { + if (vertexCount <= 0 || distanceSq.Length == 0 || thresholdSq <= 0f) + { + return false; + } + + var limit = Math.Min(vertexCount, distanceSq.Length); + var nearCount = 0; + for (var i = 0; i < limit; i++) + { + if (distanceSq[i] <= thresholdSq) + { + nearCount++; + } + } + + return nearCount >= limit * adaptiveNearRatio; + } + + private sealed record PreprocessedMeshOutput( + MeshStruct Mesh, + MdlStructs.SubmeshStruct[] SubMeshes, + byte[][] VertexStreams, + int[] Indices, + bool Decimated); + + private static bool TryBuildBodyCollisionData( + MdlFile mdl, + int lodIndex, + int lodMeshStart, + int lodMeshEnd, + ModelDecimationSettings settings, + ModelDecimationAdvancedSettings tuning, + out BodyCollisionData? bodyCollision, + out Dictionary bodyMeshOverrides, + MsLogger logger) + { + bodyCollision = null; + bodyMeshOverrides = []; + + var meshCount = Math.Max(0, lodMeshEnd - lodMeshStart); + logger.LogInformation("Body collision: scanning {MeshCount} meshes, {MaterialCount} materials", meshCount, mdl.Materials.Length); + + if (mdl.Materials.Length == 0) + { + logger.LogInformation("Body collision: no materials found, skipping body collision."); + return false; + } + + var materialList = string.Join(", ", mdl.Materials); + logger.LogInformation("Body collision: model materials = {Materials}", materialList); + logger.LogDebug("Body collision: model materials (debug) = {Materials}", materialList); + + var proxyTargetRatio = Math.Clamp(Math.Max(settings.TargetRatio, tuning.BodyProxyTargetRatioMin), 0d, 1d); + var bodyPositions = new List(); + var bodyIndices = new List(); + var foundBody = false; + + for (var meshIndex = lodMeshStart; meshIndex < lodMeshEnd; meshIndex++) + { + var mesh = mdl.Meshes[meshIndex]; + var material = mesh.MaterialIndex < mdl.Materials.Length + ? mdl.Materials[mesh.MaterialIndex] + : "(missing material)"; + var isBody = IsBodyMaterial(material); + logger.LogInformation("Body collision: mesh {MeshIndex} material {Material} body {IsBody}", meshIndex, material, isBody); + + if (!isBody) + { + continue; + } + + foundBody = true; + var meshSubMeshes = mdl.SubMeshes + .Skip(mesh.SubMeshIndex) + .Take(mesh.SubMeshCount) + .ToArray(); + + if (!TryBuildVertexFormat(mdl.VertexDeclarations[meshIndex], out var format, out var formatReason)) + { + logger.LogDebug("Body mesh {MeshIndex} vertex format unsupported: {Reason}", meshIndex, formatReason); + continue; + } + + if (!TryDecodeMeshData(mdl, lodIndex, mesh, format, meshSubMeshes, out var decoded, out var subMeshIndices, out var decodeReason)) + { + logger.LogDebug("Body mesh {MeshIndex} decode failed: {Reason}", meshIndex, decodeReason); + continue; + } + + var triangleCount = (int)(mesh.IndexCount / 3); + var updatedMesh = mesh; + var updatedSubMeshes = CopySubMeshes(meshSubMeshes, 0, mesh.StartIndex); + var vertexStreams = CopyVertexStreams(mdl, lodIndex, mesh); + var indices = ReadIndices(mdl, lodIndex, mesh); + var decimated = false; + + var collisionDecoded = decoded; + var collisionSubMeshIndices = subMeshIndices; + + if (triangleCount >= settings.TriangleThreshold) + { + var targetTriangles = (int)Math.Floor(triangleCount * proxyTargetRatio); + if (targetTriangles >= 1 && targetTriangles < triangleCount) + { + if (TryDecimateWithNanomesh(decoded, subMeshIndices, format, targetTriangles, tuning, null, out var decimatedData, out var decimatedSubMeshIndices, out _, out var decimationReason)) + { + if (TryEncodeMeshData(decimatedData, decimatedSubMeshIndices, format, mesh, meshSubMeshes, settings.NormalizeTangents, out updatedMesh, out updatedSubMeshes, out vertexStreams, out indices, out var encodeReason)) + { + decimated = true; + collisionDecoded = decimatedData; + collisionSubMeshIndices = decimatedSubMeshIndices; + } + else + { + logger.LogDebug("Body mesh {MeshIndex} encode failed: {Reason}", meshIndex, encodeReason); + } + } + else + { + logger.LogDebug("Body mesh {MeshIndex} decimation failed: {Reason}", meshIndex, decimationReason); + } + } + } + + bodyMeshOverrides[meshIndex] = new PreprocessedMeshOutput(updatedMesh, updatedSubMeshes, vertexStreams, indices, decimated); + + var baseIndex = bodyPositions.Count; + bodyPositions.AddRange(collisionDecoded.Positions); + foreach (var subMesh in collisionSubMeshIndices) + { + for (var i = 0; i < subMesh.Length; i++) + { + bodyIndices.Add(subMesh[i] + baseIndex); + } + } + } + + if (!foundBody) + { + logger.LogInformation("Body collision: no body meshes matched filter."); + return false; + } + + if (bodyPositions.Count == 0 || bodyIndices.Count == 0) + { + logger.LogDebug("Body collision enabled but no body vertices were collected."); + return false; + } + + var positionArray = bodyPositions.ToArray(); + var indexArray = bodyIndices.ToArray(); + var avgEdgeLength = ComputeAverageEdgeLength(positionArray, indexArray); + var cellSize = MathF.Max(avgEdgeLength, tuning.MinBodyCollisionCellSize); + bodyCollision = new BodyCollisionData(positionArray, indexArray, cellSize, tuning.MinBodyCollisionCellSize); + return true; + } + + private static bool IsBodyMesh(MdlFile mdl, MeshStruct mesh) + { + if (mesh.MaterialIndex >= mdl.Materials.Length) + { + return false; + } + + return IsBodyMaterial(mdl.Materials[mesh.MaterialIndex]); + } + + private static bool IsBodyMaterial(string materialPath) + { + if (string.IsNullOrWhiteSpace(materialPath)) + { + return false; + } + + var normalized = materialPath.Replace('\\', '/').ToLowerInvariant(); + var nameStart = normalized.LastIndexOf('/'); + var fileName = nameStart >= 0 ? normalized[(nameStart + 1)..] : normalized; + return fileName.Contains("_bibo", StringComparison.Ordinal) + || fileName.EndsWith("_a.mtrl", StringComparison.Ordinal); + } + + private sealed class BodyCollisionData + { + private readonly Vector3d[] _positions; + private readonly BodyTriangle[] _triangles; + private readonly Dictionary> _triangleCells; + private readonly float _cellSize; + private readonly float _cellSizeInv; + + public BodyCollisionData(Vector3d[] positions, int[] indices, float cellSize, float minCellSize) + { + _positions = positions; + _cellSize = cellSize > 0f ? cellSize : minCellSize; + _cellSizeInv = 1f / _cellSize; + + var triangles = new List(); + for (var i = 0; i + 2 < indices.Length; i += 3) + { + var a = indices[i]; + var b = indices[i + 1]; + var c = indices[i + 2]; + if ((uint)a >= _positions.Length || (uint)b >= _positions.Length || (uint)c >= _positions.Length) + { + continue; + } + + var p0 = _positions[a]; + var p1 = _positions[b]; + var p2 = _positions[c]; + var min = Vector3d.Min(p0, Vector3d.Min(p1, p2)); + var max = Vector3d.Max(p0, Vector3d.Max(p1, p2)); + triangles.Add(new BodyTriangle(a, b, c, min, max)); + } + + _triangles = triangles.ToArray(); + _triangleCells = new Dictionary>(); + + for (var triIndex = 0; triIndex < _triangles.Length; triIndex++) + { + var tri = _triangles[triIndex]; + var minCell = ToCell(tri.Min); + var maxCell = ToCell(tri.Max); + for (var x = minCell.X; x <= maxCell.X; x++) + { + for (var y = minCell.Y; y <= maxCell.Y; y++) + { + for (var z = minCell.Z; z <= maxCell.Z; z++) + { + var key = new CellKey(x, y, z); + if (!_triangleCells.TryGetValue(key, out var list)) + { + list = []; + _triangleCells[key] = list; + } + + list.Add(triIndex); + } + } + } + } + } + + public float[]? ComputeDistanceSq(Vector3d[] queryPositions, float maxDistance) + { + if (_positions.Length == 0 || queryPositions.Length == 0 || maxDistance <= 0f || _triangles.Length == 0 || _triangleCells.Count == 0) + { + return null; + } + + var result = new float[queryPositions.Length]; + var maxDistanceSq = maxDistance * maxDistance; + var radius = Math.Max(1, (int)MathF.Ceiling(maxDistance / _cellSize)); + + for (var i = 0; i < queryPositions.Length; i++) + { + var cell = ToCell(queryPositions[i]); + double minSq = double.PositiveInfinity; + var found = false; + + for (var x = -radius; x <= radius && !found; x++) + { + for (var y = -radius; y <= radius && !found; y++) + { + for (var z = -radius; z <= radius; z++) + { + var key = new CellKey(cell.X + x, cell.Y + y, cell.Z + z); + if (!_triangleCells.TryGetValue(key, out var list)) + { + continue; + } + + for (var idx = 0; idx < list.Count; idx++) + { + var tri = _triangles[list[idx]]; + var sq = PointTriangleDistanceSq(queryPositions[i], _positions[tri.A], _positions[tri.B], _positions[tri.C]); + if (sq < minSq) + { + minSq = sq; + } + + if (minSq <= maxDistanceSq) + { + found = true; + break; + } + } + } + } + } + + result[i] = minSq < double.PositiveInfinity ? (float)minSq : float.PositiveInfinity; + } + + return result; + } + + public float DistanceSq(in Vector3d point, float maxDistanceSq) + { + if (_positions.Length == 0 || _triangles.Length == 0 || _triangleCells.Count == 0) + { + return float.PositiveInfinity; + } + + if (maxDistanceSq <= 0f) + { + return float.PositiveInfinity; + } + + var maxDistance = MathF.Sqrt(maxDistanceSq); + var radius = Math.Max(1, (int)MathF.Ceiling(maxDistance / _cellSize)); + var cell = ToCell(point); + double minSq = double.PositiveInfinity; + + for (var x = -radius; x <= radius; x++) + { + for (var y = -radius; y <= radius; y++) + { + for (var z = -radius; z <= radius; z++) + { + var key = new CellKey(cell.X + x, cell.Y + y, cell.Z + z); + if (!_triangleCells.TryGetValue(key, out var list)) + { + continue; + } + + for (var idx = 0; idx < list.Count; idx++) + { + var tri = _triangles[list[idx]]; + var sq = PointTriangleDistanceSq(point, _positions[tri.A], _positions[tri.B], _positions[tri.C]); + if (sq < minSq) + { + minSq = sq; + } + + if (minSq <= maxDistanceSq) + { + return (float)minSq; + } + } + } + } + } + + return minSq < double.PositiveInfinity ? (float)minSq : float.PositiveInfinity; + } + + private CellKey ToCell(in Vector3d position) + => new( + (int)Math.Floor(position.x * _cellSizeInv), + (int)Math.Floor(position.y * _cellSizeInv), + (int)Math.Floor(position.z * _cellSizeInv)); + } + + private readonly record struct BodyTriangle(int A, int B, int C, Vector3d Min, Vector3d Max); + + private readonly record struct CellKey(int X, int Y, int Z); + + private static double PointTriangleDistanceSq(in Vector3d p, in Vector3d a, in Vector3d b, in Vector3d c) + { + var ab = b - a; + var ac = c - a; + var ap = p - a; + var d1 = Vector3d.Dot(ab, ap); + var d2 = Vector3d.Dot(ac, ap); + if (d1 <= 0d && d2 <= 0d) + { + return (p - a).LengthSquared; + } + + var bp = p - b; + var d3 = Vector3d.Dot(ab, bp); + var d4 = Vector3d.Dot(ac, bp); + if (d3 >= 0d && d4 <= d3) + { + return (p - b).LengthSquared; + } + + var vc = d1 * d4 - d3 * d2; + if (vc <= 0d && d1 >= 0d && d3 <= 0d) + { + var v = d1 / (d1 - d3); + var proj = a + ab * v; + return (p - proj).LengthSquared; + } + + var cp = p - c; + var d5 = Vector3d.Dot(ab, cp); + var d6 = Vector3d.Dot(ac, cp); + if (d6 >= 0d && d5 <= d6) + { + return (p - c).LengthSquared; + } + + var vb = d5 * d2 - d1 * d6; + if (vb <= 0d && d2 >= 0d && d6 <= 0d) + { + var w = d2 / (d2 - d6); + var proj = a + ac * w; + return (p - proj).LengthSquared; + } + + var va = d3 * d6 - d5 * d4; + if (va <= 0d && (d4 - d3) >= 0d && (d5 - d6) >= 0d) + { + var w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); + var proj = b + (c - b) * w; + return (p - proj).LengthSquared; + } + + var denom = 1d / (va + vb + vc); + var v2 = vb * denom; + var w2 = vc * denom; + var projPoint = a + ab * v2 + ac * w2; + return (p - projPoint).LengthSquared; + } + + private static bool AppendComponentData( + DecodedMeshData component, + int[] componentIndices, + VertexFormat format, + List positions, + List? normals, + List? tangents, + List? tangents2, + List? colors, + List? boneWeights, + List[]? uvChannels, + List? positionWs, + List? normalWs, + List outputIndices, + out string? reason) + { + reason = null; + + if (component.Positions.Length == 0 || componentIndices.Length == 0) + { + return true; + } + + var baseIndex = positions.Count; + positions.AddRange(component.Positions); + + if (normals != null && component.Normals != null) + { + normals.AddRange(component.Normals); + } + if (tangents != null && component.Tangents != null) + { + tangents.AddRange(component.Tangents); + } + if (tangents2 != null && component.Tangents2 != null) + { + tangents2.AddRange(component.Tangents2); + } + if (colors != null && component.Colors != null) + { + colors.AddRange(component.Colors); + } + if (boneWeights != null && component.BoneWeights != null) + { + boneWeights.AddRange(component.BoneWeights); + } + if (positionWs != null && component.PositionWs != null) + { + positionWs.AddRange(component.PositionWs); + } + if (normalWs != null && component.NormalWs != null) + { + normalWs.AddRange(component.NormalWs); + } + if (uvChannels != null && component.UvChannels != null) + { + if (uvChannels.Length != component.UvChannels.Length) + { + reason = "UV channel mismatch while merging components."; + return false; + } + + for (var channel = 0; channel < uvChannels.Length; channel++) + { + uvChannels[channel].AddRange(component.UvChannels[channel]); + } + } + + for (var i = 0; i < componentIndices.Length; i++) + { + outputIndices.Add(componentIndices[i] + baseIndex); + } + + return true; + } + + private static int Find(int[] parent, int value) + { + var root = value; + while (parent[root] != root) + { + root = parent[root]; + } + + while (parent[value] != value) + { + var next = parent[value]; + parent[value] = root; + value = next; + } + + return root; + } + + private static void Union(int[] parent, byte[] rank, int a, int b) + { + var rootA = Find(parent, a); + var rootB = Find(parent, b); + if (rootA == rootB) + { + return; + } + + if (rank[rootA] < rank[rootB]) + { + parent[rootA] = rootB; + return; + } + + parent[rootB] = rootA; + if (rank[rootA] == rank[rootB]) + { + rank[rootA]++; + } + } + + private static bool TryBuildNanomeshMesh( + DecodedMeshData decoded, + int[][] subMeshIndices, + VertexFormat format, + out Nano.SharedMesh sharedMesh, + out string? reason) + { + sharedMesh = default!; + reason = null; + + var vertexCount = decoded.Positions.Length; + if (vertexCount == 0) + { + reason = "No vertices to decimate."; + return false; + } + + if (subMeshIndices.Length == 0) + { + reason = "No submesh indices."; + return false; + } + + var positions = decoded.Positions; + + var totalIndexCount = 0; + for (var i = 0; i < subMeshIndices.Length; i++) + { + totalIndexCount += subMeshIndices[i].Length; + } + + var triangles = new int[totalIndexCount]; + var groups = new Nano.Group[subMeshIndices.Length]; + var cursor = 0; + for (var i = 0; i < subMeshIndices.Length; i++) + { + var subMesh = subMeshIndices[i]; + if (subMesh.Length > 0) + { + Array.Copy(subMesh, 0, triangles, cursor, subMesh.Length); + } + groups[i] = new Nano.Group { firstIndex = cursor, indexCount = subMesh.Length }; + cursor += subMesh.Length; + } + + var flags = BuildFfxivAttributeFlags(format); + var attributes = new Nano.MetaAttributeList(vertexCount); + + for (var i = 0; i < vertexCount; i++) + { + var attr = new Nano.FfxivVertexAttribute( + flags, + format.HasNormals && decoded.Normals != null ? decoded.Normals[i] : default, + format.HasTangent1 && decoded.Tangents != null ? decoded.Tangents[i] : default, + format.HasTangent2 && decoded.Tangents2 != null ? decoded.Tangents2[i] : default, + format.UvChannelCount > 0 && decoded.UvChannels != null ? decoded.UvChannels[0][i] : default, + format.UvChannelCount > 1 && decoded.UvChannels != null ? decoded.UvChannels[1][i] : default, + format.UvChannelCount > 2 && decoded.UvChannels != null ? decoded.UvChannels[2][i] : default, + format.UvChannelCount > 3 && decoded.UvChannels != null ? decoded.UvChannels[3][i] : default, + format.HasColors && decoded.Colors != null ? decoded.Colors[i] : default, + format.HasSkinning && decoded.BoneWeights != null ? decoded.BoneWeights[i] : default, + format.HasPositionW && decoded.PositionWs != null ? decoded.PositionWs[i] : 0f, + format.HasNormalW && decoded.NormalWs != null ? decoded.NormalWs[i] : 0f); + + attributes[i] = new Nano.MetaAttribute(attr); + } + + sharedMesh = new Nano.SharedMesh + { + positions = positions, + triangles = triangles, + groups = groups, + attributes = attributes, + attributeDefinitions = [new Nano.AttributeDefinition(Nano.AttributeType.Normals, Nano.ConnectedMesh.EdgeBorderPenalty, 0)], + }; + + return true; + } + + private static bool TryConvertNanomeshMesh( + Nano.SharedMesh decimatedShared, + VertexFormat format, + int expectedSubMeshCount, + BlendWeightEncoding blendWeightEncoding, + out DecodedMeshData decimated, + out int[][] decimatedSubMeshIndices, + out string? reason) + { + decimated = default!; + decimatedSubMeshIndices = []; + reason = null; + + if (decimatedShared.triangles == null || decimatedShared.triangles.Length == 0) + { + reason = "No triangles after decimation."; + return false; + } + + var groups = decimatedShared.groups; + var triangles = decimatedShared.triangles; + int[][] subMeshIndices; + + if (groups != null && groups.Length == expectedSubMeshCount) + { + subMeshIndices = new int[groups.Length][]; + for (var i = 0; i < groups.Length; i++) + { + var group = groups[i]; + if (group.firstIndex < 0 || group.indexCount < 0 || group.firstIndex + group.indexCount > triangles.Length) + { + reason = "Invalid submesh group range after decimation."; + return false; + } + + var slice = new int[group.indexCount]; + if (group.indexCount > 0) + { + Array.Copy(triangles, group.firstIndex, slice, 0, group.indexCount); + } + subMeshIndices[i] = slice; + } + } + else if (expectedSubMeshCount == 1) + { + subMeshIndices = [triangles]; + } + else + { + reason = "Submesh group count mismatch after decimation."; + return false; + } + + var vertexCount = decimatedShared.positions.Length; + var positions = decimatedShared.positions; + + var attrList = decimatedShared.attributes as Nano.MetaAttributeList; + if (attrList == null) + { + reason = "Missing vertex attributes after decimation."; + return false; + } + + Vector3[]? normals = format.HasNormals ? new Vector3[vertexCount] : null; + Vector4[]? tangents = format.HasTangent1 ? new Vector4[vertexCount] : null; + Vector4[]? tangents2 = format.HasTangent2 ? new Vector4[vertexCount] : null; + Vector4[]? colors = format.HasColors ? new Vector4[vertexCount] : null; + BoneWeight[]? boneWeights = format.HasSkinning ? new BoneWeight[vertexCount] : null; + float[]? positionWs = format.HasPositionW ? new float[vertexCount] : null; + float[]? normalWs = format.HasNormalW ? new float[vertexCount] : null; + + Vector2[][]? uvChannels = null; + if (format.UvChannelCount > 0) + { + uvChannels = new Vector2[format.UvChannelCount][]; + for (var channel = 0; channel < format.UvChannelCount; channel++) + { + uvChannels[channel] = new Vector2[vertexCount]; + } + } + + for (var i = 0; i < vertexCount; i++) + { + var attr = (Nano.MetaAttribute)attrList[i]; + var data = attr.attr0; + + if (normals != null) + { + normals[i] = data.normal; + } + + if (tangents != null) + { + tangents[i] = data.tangent1; + } + + if (tangents2 != null) + { + tangents2[i] = data.tangent2; + } + + if (colors != null) + { + colors[i] = data.color; + } + + if (boneWeights != null) + { + boneWeights[i] = data.boneWeight; + } + + if (positionWs != null) + { + positionWs[i] = data.positionW; + } + + if (normalWs != null) + { + normalWs[i] = data.normalW; + } + + if (uvChannels != null) + { + if (uvChannels.Length > 0) + { + uvChannels[0][i] = data.uv0; + } + if (uvChannels.Length > 1) + { + uvChannels[1][i] = data.uv1; + } + if (uvChannels.Length > 2) + { + uvChannels[2][i] = data.uv2; + } + if (uvChannels.Length > 3) + { + uvChannels[3][i] = data.uv3; + } + } + } + + decimated = new DecodedMeshData(positions, normals, tangents, tangents2, colors, boneWeights, uvChannels, positionWs, normalWs, blendWeightEncoding); + decimatedSubMeshIndices = subMeshIndices; + return true; + } + + private static Nano.FfxivAttributeFlags BuildFfxivAttributeFlags(VertexFormat format) + { + var flags = Nano.FfxivAttributeFlags.None; + if (format.HasNormals) + { + flags |= Nano.FfxivAttributeFlags.Normal; + } + if (format.HasTangent1) + { + flags |= Nano.FfxivAttributeFlags.Tangent1; + } + if (format.HasTangent2) + { + flags |= Nano.FfxivAttributeFlags.Tangent2; + } + if (format.HasColors) + { + flags |= Nano.FfxivAttributeFlags.Color; + } + if (format.HasSkinning) + { + flags |= Nano.FfxivAttributeFlags.BoneWeights; + } + if (format.HasPositionW) + { + flags |= Nano.FfxivAttributeFlags.PositionW; + } + if (format.HasNormalW) + { + flags |= Nano.FfxivAttributeFlags.NormalW; + } + if (format.UvChannelCount > 0) + { + flags |= Nano.FfxivAttributeFlags.Uv0; + } + if (format.UvChannelCount > 1) + { + flags |= Nano.FfxivAttributeFlags.Uv1; + } + if (format.UvChannelCount > 2) + { + flags |= Nano.FfxivAttributeFlags.Uv2; + } + if (format.UvChannelCount > 3) + { + flags |= Nano.FfxivAttributeFlags.Uv3; + } + return flags; } private static bool TryDecodeMeshData( @@ -494,6 +2027,8 @@ internal static class MdlDecimator } } + var blendWeightEncoding = DetectBlendWeightEncoding(mdl, lodIndex, mesh, format); + var streams = new BinaryReader[MaxStreams]; for (var streamIndex = 0; streamIndex < MaxStreams; streamIndex++) { @@ -548,7 +2083,7 @@ internal static class MdlDecimator indices = ReadIndices(type, stream); break; case MdlFile.VertexUsage.BlendWeights: - weights = ReadWeights(type, stream); + weights = ReadWeights(type, stream, blendWeightEncoding); break; case MdlFile.VertexUsage.UV when uvChannels != null: if (!uvLookup.TryGetValue(ElementKey.From(element), out var uvElement)) @@ -577,17 +2112,17 @@ internal static class MdlDecimator return false; } - NormalizeWeights(weights); boneWeights[vertexIndex] = new BoneWeight(indices[0], indices[1], indices[2], indices[3], weights[0], weights[1], weights[2], weights[3]); } } - decoded = new DecodedMeshData(positions, normals, tangents, tangents2, colors, boneWeights, uvChannels, positionWs, normalWs); + decoded = new DecodedMeshData(positions, normals, tangents, tangents2, colors, boneWeights, uvChannels, positionWs, normalWs, blendWeightEncoding); return true; } private static bool TryEncodeMeshData( - Mesh decimatedMesh, + DecodedMeshData decimated, + int[][] decimatedSubMeshIndices, VertexFormat format, MeshStruct originalMesh, MdlStructs.SubmeshStruct[] originalSubMeshes, @@ -604,20 +2139,26 @@ internal static class MdlDecimator indices = []; reason = null; - var vertexCount = decimatedMesh.Vertices.Length; + if (decimatedSubMeshIndices.Length != originalSubMeshes.Length) + { + reason = "Decimated submesh count mismatch."; + return false; + } + + var vertexCount = decimated.Positions.Length; if (vertexCount > ushort.MaxValue) { reason = "Vertex count exceeds ushort range."; return false; } - var normals = decimatedMesh.Normals; - var tangents = decimatedMesh.Tangents; - var tangents2 = decimatedMesh.Tangents2; - var colors = decimatedMesh.Colors; - var boneWeights = decimatedMesh.BoneWeights; - var positionWs = decimatedMesh.PositionWs; - var normalWs = decimatedMesh.NormalWs; + var normals = decimated.Normals; + var tangents = decimated.Tangents; + var tangents2 = decimated.Tangents2; + var colors = decimated.Colors; + var boneWeights = decimated.BoneWeights; + var positionWs = decimated.PositionWs; + var normalWs = decimated.NormalWs; if (format.HasNormals && normals == null) { @@ -670,16 +2211,13 @@ internal static class MdlDecimator var uvChannels = Array.Empty(); if (format.UvChannelCount > 0) { - uvChannels = new Vector2[format.UvChannelCount][]; - for (var channel = 0; channel < format.UvChannelCount; channel++) + if (decimated.UvChannels == null || decimated.UvChannels.Length < format.UvChannelCount) { - if (decimatedMesh.GetUVDimension(channel) != 2) - { - reason = "Unsupported UV dimension after decimation."; - return false; - } - uvChannels[channel] = decimatedMesh.GetUVs2D(channel); + reason = "Missing UV channels after decimation."; + return false; } + + uvChannels = decimated.UvChannels; } var streamBuffers = new byte[MaxStreams][]; @@ -732,7 +2270,7 @@ internal static class MdlDecimator switch (usage) { case MdlFile.VertexUsage.Position: - WritePosition(type, decimatedMesh.Vertices[vertexIndex], target, positionWs != null ? positionWs[vertexIndex] : null); + WritePosition(type, decimated.Positions[vertexIndex], target, positionWs != null ? positionWs[vertexIndex] : null); break; case MdlFile.VertexUsage.Normal when normals != null: WriteNormal(type, normals[vertexIndex], target, normalWs != null ? normalWs[vertexIndex] : null); @@ -750,7 +2288,7 @@ internal static class MdlDecimator WriteBlendIndices(type, boneWeights[vertexIndex], target); break; case MdlFile.VertexUsage.BlendWeights when boneWeights != null: - WriteBlendWeights(type, boneWeights[vertexIndex], target); + WriteBlendWeights(type, boneWeights[vertexIndex], decimated.BlendWeightEncoding, target); break; case MdlFile.VertexUsage.UV when format.UvChannelCount > 0: if (!uvLookup.TryGetValue(ElementKey.From(element), out var uvElement)) @@ -771,7 +2309,7 @@ internal static class MdlDecimator for (var subMeshIndex = 0; subMeshIndex < originalSubMeshes.Length; subMeshIndex++) { - var subMeshIndices = decimatedMesh.GetIndices(subMeshIndex); + var subMeshIndices = decimatedSubMeshIndices[subMeshIndex]; if (subMeshIndices.Any(index => index < 0 || index >= vertexCount)) { reason = "Decimated indices out of range."; @@ -1106,7 +2644,7 @@ internal static class MdlDecimator || type == MdlFile.VertexType.UShort2 || type == MdlFile.VertexType.Single1) { - if (uvChannelCount + 1 > Mesh.UVChannelCount) + if (uvChannelCount + 1 > MaxUvChannels) { reason = "Too many UV channels."; return false; @@ -1121,7 +2659,7 @@ internal static class MdlDecimator || type == MdlFile.VertexType.NShort4 || type == MdlFile.VertexType.UShort4) { - if (uvChannelCount + 2 > Mesh.UVChannelCount) + if (uvChannelCount + 2 > MaxUvChannels) { reason = "Too many UV channels."; return false; @@ -1237,20 +2775,24 @@ internal static class MdlDecimator for (var i = 0; i < tangents.Length; i++) { var tangent = tangents[i]; - var length = MathF.Sqrt(tangent.x * tangent.x + tangent.y * tangent.y + tangent.z * tangent.z); + var x = tangent.x; + var y = tangent.y; + var z = tangent.z; + var w = tangent.w; + var length = MathF.Sqrt((x * x) + (y * y) + (z * z)); if (length > 1e-6f) { - tangent.x /= length; - tangent.y /= length; - tangent.z /= length; + x /= length; + y /= length; + z /= length; } if (clampW) { - tangent.w = tangent.w >= 0f ? 1f : -1f; + w = w >= 0f ? 1f : -1f; } - tangents[i] = tangent; + tangents[i] = new Vector4(x, y, z, w); } } @@ -1271,7 +2813,7 @@ internal static class MdlDecimator MdlFile.VertexType.NShort2 => ReadUShort2Normalized(reader), MdlFile.VertexType.UShort2 => ReadUShort2Normalized(reader), MdlFile.VertexType.Single1 => new Vector2(reader.ReadSingle(), 0f), - _ => Vector2.zero, + _ => Vector2.Zero, }; uvChannels[mapping.FirstChannel][vertexIndex] = uv; @@ -1291,7 +2833,7 @@ internal static class MdlDecimator MdlFile.VertexType.Short4 => ReadShort4(reader), MdlFile.VertexType.NShort4 => ReadUShort4Normalized(reader), MdlFile.VertexType.UShort4 => ReadUShort4Normalized(reader), - _ => Vector4.zero, + _ => new Vector4(0f, 0f, 0f, 0f), }; uvChannels[mapping.FirstChannel][vertexIndex] = new Vector2(uv.x, uv.y); @@ -1312,7 +2854,7 @@ internal static class MdlDecimator }; } - private static float[] ReadWeights(MdlFile.VertexType type, BinaryReader reader) + private static float[] ReadWeights(MdlFile.VertexType type, BinaryReader reader, BlendWeightEncoding encoding) { return type switch { @@ -1320,11 +2862,22 @@ internal static class MdlDecimator MdlFile.VertexType.NByte4 => ReadUByte4(reader).ToFloatArray(), MdlFile.VertexType.Single4 => new[] { reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle() }, MdlFile.VertexType.NShort4 => ReadUShort4Normalized(reader).ToFloatArray(), - MdlFile.VertexType.UShort4 => ReadUShort4Normalized(reader).ToFloatArray(), + MdlFile.VertexType.UShort4 => encoding == BlendWeightEncoding.UShortAsByte + ? ReadUShort4AsByte(reader) + : ReadUShort4Normalized(reader).ToFloatArray(), _ => throw new InvalidOperationException($"Unsupported weights type {type}"), }; } + private static float[] ReadUShort4AsByte(BinaryReader reader) + { + var w0 = reader.ReadUInt16(); + var w1 = reader.ReadUInt16(); + var w2 = reader.ReadUInt16(); + var w3 = reader.ReadUInt16(); + return new[] { w0 / 255f, w1 / 255f, w2 / 255f, w3 / 255f }; + } + private static Vector4 ReadUByte4(BinaryReader reader) { return new Vector4( @@ -1408,7 +2961,7 @@ internal static class MdlDecimator case MdlFile.VertexType.UShort4: return ReadUShort4Normalized(reader); default: - return Vector4.zero; + return new Vector4(0f, 0f, 0f, 0f); } } @@ -1469,30 +3022,30 @@ internal static class MdlDecimator { if (type == MdlFile.VertexType.UByte4) { - target[0] = (byte)Math.Clamp(weights.boneIndex0, 0, 255); - target[1] = (byte)Math.Clamp(weights.boneIndex1, 0, 255); - target[2] = (byte)Math.Clamp(weights.boneIndex2, 0, 255); - target[3] = (byte)Math.Clamp(weights.boneIndex3, 0, 255); + target[0] = (byte)Math.Clamp(weights.index0, 0, 255); + target[1] = (byte)Math.Clamp(weights.index1, 0, 255); + target[2] = (byte)Math.Clamp(weights.index2, 0, 255); + target[3] = (byte)Math.Clamp(weights.index3, 0, 255); return; } if (type == MdlFile.VertexType.UShort4) { - BinaryPrimitives.WriteUInt16LittleEndian(target[..2], ToUShort(weights.boneIndex0)); - BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(2, 2), ToUShort(weights.boneIndex1)); - BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(4, 2), ToUShort(weights.boneIndex2)); - BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(6, 2), ToUShort(weights.boneIndex3)); + BinaryPrimitives.WriteUInt16LittleEndian(target[..2], ToUShort(weights.index0)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(2, 2), ToUShort(weights.index1)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(4, 2), ToUShort(weights.index2)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(6, 2), ToUShort(weights.index3)); } } - private static void WriteBlendWeights(MdlFile.VertexType type, BoneWeight weights, Span target) + private static void WriteBlendWeights(MdlFile.VertexType type, BoneWeight weights, BlendWeightEncoding encoding, Span target) { if (type == MdlFile.VertexType.Single4) { - BinaryPrimitives.WriteSingleLittleEndian(target[..4], weights.boneWeight0); - BinaryPrimitives.WriteSingleLittleEndian(target.Slice(4, 4), weights.boneWeight1); - BinaryPrimitives.WriteSingleLittleEndian(target.Slice(8, 4), weights.boneWeight2); - BinaryPrimitives.WriteSingleLittleEndian(target.Slice(12, 4), weights.boneWeight3); + BinaryPrimitives.WriteSingleLittleEndian(target[..4], weights.weight0); + BinaryPrimitives.WriteSingleLittleEndian(target.Slice(4, 4), weights.weight1); + BinaryPrimitives.WriteSingleLittleEndian(target.Slice(8, 4), weights.weight2); + BinaryPrimitives.WriteSingleLittleEndian(target.Slice(12, 4), weights.weight3); return; } @@ -1504,11 +3057,16 @@ internal static class MdlDecimator return; } - var w0 = Clamp01(weights.boneWeight0); - var w1 = Clamp01(weights.boneWeight1); - var w2 = Clamp01(weights.boneWeight2); - var w3 = Clamp01(weights.boneWeight3); - NormalizeWeights(ref w0, ref w1, ref w2, ref w3); + var w0 = Clamp01(weights.weight0); + var w1 = Clamp01(weights.weight1); + var w2 = Clamp01(weights.weight2); + var w3 = Clamp01(weights.weight3); + + if (type == MdlFile.VertexType.UShort4 && encoding == BlendWeightEncoding.UShortAsByte) + { + WriteUShort4AsByte(w0, w1, w2, w3, target); + return; + } if (type == MdlFile.VertexType.UShort4 || type == MdlFile.VertexType.NShort4) { @@ -1519,10 +3077,16 @@ internal static class MdlDecimator return; } - target[0] = ToByte(w0); - target[1] = ToByte(w1); - target[2] = ToByte(w2); - target[3] = ToByte(w3); + WriteByteWeights(w0, w1, w2, w3, target); + } + + private static void WriteUShort4AsByte(float w0, float w1, float w2, float w3, Span target) + { + QuantizeByteWeights(w0, w1, w2, w3, out var b0, out var b1, out var b2, out var b3); + BinaryPrimitives.WriteUInt16LittleEndian(target[..2], (ushort)b0); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(2, 2), (ushort)b1); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(4, 2), (ushort)b2); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(6, 2), (ushort)b3); } private static void WriteUv(MdlFile.VertexType type, UvElementPacking mapping, Vector2[][] uvChannels, int vertexIndex, Span target) @@ -1548,7 +3112,7 @@ internal static class MdlDecimator var uv0 = uvChannels[mapping.FirstChannel][vertexIndex]; var uv1 = mapping.SecondChannel.HasValue ? uvChannels[mapping.SecondChannel.Value][vertexIndex] - : Vector2.zero; + : Vector2.Zero; WriteVector4(type, new Vector4(uv0.x, uv0.y, uv1.x, uv1.y), target); } } @@ -1672,7 +3236,7 @@ internal static class MdlDecimator private static void WriteNByte4(Vector4 value, Span target) { - var normalized = (value * 0.5f) + new Vector4(0.5f); + var normalized = (value * 0.5f) + new Vector4(0.5f, 0.5f, 0.5f, 0.5f); WriteUByte4(normalized, target); } @@ -1746,6 +3310,101 @@ internal static class MdlDecimator private static byte ToByte(float value) => (byte)Math.Clamp((int)Math.Round(value * 255f), 0, 255); + private static void WriteByteWeights(float w0, float w1, float w2, float w3, Span target) + { + QuantizeByteWeights(w0, w1, w2, w3, out var b0, out var b1, out var b2, out var b3); + target[0] = (byte)b0; + target[1] = (byte)b1; + target[2] = (byte)b2; + target[3] = (byte)b3; + } + + private static void QuantizeByteWeights(float w0, float w1, float w2, float w3, out int b0, out int b1, out int b2, out int b3) + { + var sum = w0 + w1 + w2 + w3; + if (sum <= 1e-6f) + { + w0 = 1f; + w1 = 0f; + w2 = 0f; + w3 = 0f; + sum = 1f; + } + + var targetSum = (int)MathF.Round(sum * 255f); + if (sum > 0f && targetSum == 0) + { + targetSum = 1; + } + + targetSum = Math.Clamp(targetSum, 0, 255); + if (targetSum == 0) + { + b0 = 0; + b1 = 0; + b2 = 0; + b3 = 0; + return; + } + + var scale = targetSum / sum; + var scaled0 = w0 * scale; + var scaled1 = w1 * scale; + var scaled2 = w2 * scale; + var scaled3 = w3 * scale; + + b0 = (int)MathF.Floor(scaled0); + b1 = (int)MathF.Floor(scaled1); + b2 = (int)MathF.Floor(scaled2); + b3 = (int)MathF.Floor(scaled3); + + var remainder = targetSum - (b0 + b1 + b2 + b3); + if (remainder > 0) + { + Span fractions = stackalloc float[4]; + fractions[0] = scaled0 - b0; + fractions[1] = scaled1 - b1; + fractions[2] = scaled2 - b2; + fractions[3] = scaled3 - b3; + + Span order = stackalloc int[4] { 0, 1, 2, 3 }; + for (var i = 0; i < order.Length - 1; i++) + { + for (var j = i + 1; j < order.Length; j++) + { + if (fractions[order[j]] > fractions[order[i]]) + { + (order[i], order[j]) = (order[j], order[i]); + } + } + } + + for (var i = 0; i < remainder && i < order.Length; i++) + { + switch (order[i]) + { + case 0: + b0++; + break; + case 1: + b1++; + break; + case 2: + b2++; + break; + case 3: + b3++; + break; + } + } + } + + b0 = Math.Clamp(b0, 0, 255); + b1 = Math.Clamp(b1, 0, 255); + b2 = Math.Clamp(b2, 0, 255); + b3 = Math.Clamp(b3, 0, 255); + } + private static short ToShort(float value) => (short)Math.Clamp((int)Math.Round(value), short.MinValue, short.MaxValue); @@ -1794,6 +3453,50 @@ internal static class MdlDecimator w3 /= sum; } + private static BlendWeightEncoding DetectBlendWeightEncoding(MdlFile mdl, int lodIndex, MeshStruct mesh, VertexFormat format) + { + if (!format.BlendWeightsElement.HasValue) + { + return BlendWeightEncoding.Default; + } + + var blendWeightsElement = format.BlendWeightsElement.Value; + if ((MdlFile.VertexType)blendWeightsElement.Type != MdlFile.VertexType.UShort4) + { + return BlendWeightEncoding.Default; + } + + var stride = mesh.VertexBufferStride(blendWeightsElement.Stream); + if (stride == 0 || mesh.VertexCount == 0) + { + return BlendWeightEncoding.Default; + } + + var elementSize = GetElementSize(MdlFile.VertexType.UShort4); + var baseOffset = (int)(mdl.VertexOffset[lodIndex] + mesh.VertexBufferOffset(blendWeightsElement.Stream)); + var data = mdl.RemainingData.AsSpan(); + + for (var vertexIndex = 0; vertexIndex < mesh.VertexCount; vertexIndex++) + { + var offset = baseOffset + (vertexIndex * stride) + blendWeightsElement.Offset; + if (offset < 0 || offset + elementSize > data.Length) + { + return BlendWeightEncoding.Default; + } + + var w0 = BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(offset, 2)); + var w1 = BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(offset + 2, 2)); + var w2 = BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(offset + 4, 2)); + var w3 = BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(offset + 6, 2)); + if (w0 > byte.MaxValue || w1 > byte.MaxValue || w2 > byte.MaxValue || w3 > byte.MaxValue) + { + return BlendWeightEncoding.Default; + } + } + + return BlendWeightEncoding.UShortAsByte; + } + private static int GetElementSize(MdlFile.VertexType type) => type switch { @@ -1814,6 +3517,12 @@ internal static class MdlDecimator _ => throw new InvalidOperationException($"Unsupported vertex type {type}"), }; + private enum BlendWeightEncoding + { + Default, + UShortAsByte, + } + private readonly record struct ElementKey(byte Stream, byte Offset, byte Type, byte Usage, byte UsageIndex) { public static ElementKey From(MdlStructs.VertexElement element) @@ -1879,7 +3588,8 @@ internal static class MdlDecimator BoneWeight[]? boneWeights, Vector2[][]? uvChannels, float[]? positionWs, - float[]? normalWs) + float[]? normalWs, + BlendWeightEncoding blendWeightEncoding) { Positions = positions; Normals = normals; @@ -1890,6 +3600,7 @@ internal static class MdlDecimator UvChannels = uvChannels; PositionWs = positionWs; NormalWs = normalWs; + BlendWeightEncoding = blendWeightEncoding; } public Vector3d[] Positions { get; } @@ -1901,10 +3612,11 @@ internal static class MdlDecimator public Vector2[][]? UvChannels { get; } public float[]? PositionWs { get; } public float[]? NormalWs { get; } + public BlendWeightEncoding BlendWeightEncoding { get; } } } -internal static class MeshDecimatorVectorExtensions +internal static class NanomeshVectorExtensions { public static Vector3 ToVector3(this Vector4 value) => new(value.x, value.y, value.z); diff --git a/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs b/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs index 98f1f88..0195a0c 100644 --- a/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs +++ b/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs @@ -1,5 +1,6 @@ using LightlessSync.FileCache; using LightlessSync.LightlessConfiguration; +using LightlessSync.LightlessConfiguration.Configurations; using LightlessSync.Utils; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; @@ -71,11 +72,50 @@ public sealed class ModelDecimationService }); } + public void ScheduleBatchDecimation(string hash, string filePath, ModelDecimationSettings settings) + { + if (!ShouldScheduleBatchDecimation(hash, filePath, settings)) + { + return; + } + + if (_decimationDeduplicator.TryGetExisting(hash, out _)) + { + return; + } + + _failedHashes.TryRemove(hash, out _); + _decimatedPaths.TryRemove(hash, out _); + + _logger.LogInformation("Queued batch model decimation for {Hash}", hash); + + _decimationDeduplicator.GetOrStart(hash, async () => + { + await _decimationSemaphore.WaitAsync().ConfigureAwait(false); + try + { + await DecimateInternalAsync(hash, filePath, settings, allowExisting: false, destinationOverride: filePath, registerDecimatedPath: false).ConfigureAwait(false); + } + catch (Exception ex) + { + _failedHashes[hash] = 1; + _logger.LogWarning(ex, "Batch model decimation failed for {Hash}", hash); + } + finally + { + _decimationSemaphore.Release(); + } + }); + } + public bool ShouldScheduleDecimation(string hash, string filePath, string? gamePath = null) - => IsDecimationEnabled() + { + var threshold = Math.Max(0, _performanceConfigService.Current.ModelDecimationTriangleThreshold); + return IsDecimationEnabled() && filePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase) && IsDecimationAllowed(gamePath) - && !ShouldSkipByTriangleCache(hash); + && !ShouldSkipByTriangleCache(hash, threshold); + } public string GetPreferredPath(string hash, string originalPath) { @@ -131,6 +171,23 @@ public sealed class ModelDecimationService } private Task DecimateInternalAsync(string hash, string sourcePath) + { + if (!TryGetDecimationSettings(out var settings)) + { + _logger.LogInformation("Model decimation disabled or invalid settings for {Hash}", hash); + return Task.CompletedTask; + } + + return DecimateInternalAsync(hash, sourcePath, settings, allowExisting: true); + } + + private Task DecimateInternalAsync( + string hash, + string sourcePath, + ModelDecimationSettings settings, + bool allowExisting, + string? destinationOverride = null, + bool registerDecimatedPath = true) { if (!File.Exists(sourcePath)) { @@ -139,34 +196,47 @@ public sealed class ModelDecimationService return Task.CompletedTask; } - if (!TryGetDecimationSettings(out var triangleThreshold, out var targetRatio, out var normalizeTangents)) + if (!TryNormalizeSettings(settings, out var normalized)) { - _logger.LogInformation("Model decimation disabled or invalid settings for {Hash}", hash); + _logger.LogInformation("Model decimation skipped for {Hash}; invalid settings.", hash); return Task.CompletedTask; } _logger.LogInformation( - "Starting model decimation for {Hash} (threshold {Threshold}, ratio {Ratio:0.##}, normalize tangents {NormalizeTangents})", + "Starting model decimation for {Hash} (threshold {Threshold}, ratio {Ratio:0.##}, normalize tangents {NormalizeTangents}, avoid body intersection {AvoidBodyIntersection})", hash, - triangleThreshold, - targetRatio, - normalizeTangents); + normalized.TriangleThreshold, + normalized.TargetRatio, + normalized.NormalizeTangents, + normalized.AvoidBodyIntersection); - var destination = Path.Combine(GetDecimatedDirectory(), $"{hash}.mdl"); - if (File.Exists(destination)) + var destination = destinationOverride ?? Path.Combine(GetDecimatedDirectory(), $"{hash}.mdl"); + var inPlace = string.Equals(destination, sourcePath, StringComparison.OrdinalIgnoreCase); + if (!inPlace && File.Exists(destination)) { - RegisterDecimatedModel(hash, sourcePath, destination); - return Task.CompletedTask; + if (allowExisting) + { + if (registerDecimatedPath) + { + RegisterDecimatedModel(hash, sourcePath, destination); + } + return Task.CompletedTask; + } + + TryDelete(destination); } - if (!MdlDecimator.TryDecimate(sourcePath, destination, triangleThreshold, targetRatio, normalizeTangents, _logger)) + if (!MdlDecimator.TryDecimate(sourcePath, destination, normalized, _logger)) { _failedHashes[hash] = 1; _logger.LogInformation("Model decimation skipped for {Hash}", hash); return Task.CompletedTask; } - RegisterDecimatedModel(hash, sourcePath, destination); + if (registerDecimatedPath) + { + RegisterDecimatedModel(hash, sourcePath, destination); + } _logger.LogInformation("Decimated model {Hash} -> {Path}", hash, destination); return Task.CompletedTask; } @@ -255,7 +325,7 @@ public sealed class ModelDecimationService private bool IsDecimationEnabled() => _performanceConfigService.Current.EnableModelDecimation; - private bool ShouldSkipByTriangleCache(string hash) + private bool ShouldSkipByTriangleCache(string hash, int triangleThreshold) { if (string.IsNullOrEmpty(hash)) { @@ -267,7 +337,7 @@ public sealed class ModelDecimationService return false; } - var threshold = Math.Max(0, _performanceConfigService.Current.ModelDecimationTriangleThreshold); + var threshold = Math.Max(0, triangleThreshold); return threshold > 0 && cachedTris < threshold; } @@ -318,11 +388,14 @@ public sealed class ModelDecimationService private static string NormalizeGamePath(string path) => path.Replace('\\', '/').ToLowerInvariant(); - private bool TryGetDecimationSettings(out int triangleThreshold, out double targetRatio, out bool normalizeTangents) + private bool TryGetDecimationSettings(out ModelDecimationSettings settings) { - triangleThreshold = 15_000; - targetRatio = 0.8; - normalizeTangents = true; + settings = new ModelDecimationSettings( + ModelDecimationDefaults.TriangleThreshold, + ModelDecimationDefaults.TargetRatio, + ModelDecimationDefaults.NormalizeTangents, + ModelDecimationDefaults.AvoidBodyIntersection, + new ModelDecimationAdvancedSettings()); var config = _performanceConfigService.Current; if (!config.EnableModelDecimation) @@ -330,15 +403,86 @@ public sealed class ModelDecimationService return false; } - triangleThreshold = Math.Max(0, config.ModelDecimationTriangleThreshold); - targetRatio = config.ModelDecimationTargetRatio; - normalizeTangents = config.ModelDecimationNormalizeTangents; - if (double.IsNaN(targetRatio) || double.IsInfinity(targetRatio)) + var advanced = NormalizeAdvancedSettings(config.ModelDecimationAdvanced); + settings = new ModelDecimationSettings( + Math.Max(0, config.ModelDecimationTriangleThreshold), + config.ModelDecimationTargetRatio, + config.ModelDecimationNormalizeTangents, + config.ModelDecimationAvoidBodyIntersection, + advanced); + + return TryNormalizeSettings(settings, out settings); + } + + private static bool TryNormalizeSettings(ModelDecimationSettings settings, out ModelDecimationSettings normalized) + { + var ratio = settings.TargetRatio; + if (double.IsNaN(ratio) || double.IsInfinity(ratio)) + { + normalized = default; + return false; + } + + ratio = Math.Clamp(ratio, MinTargetRatio, MaxTargetRatio); + var advanced = NormalizeAdvancedSettings(settings.Advanced); + normalized = new ModelDecimationSettings( + Math.Max(0, settings.TriangleThreshold), + ratio, + settings.NormalizeTangents, + settings.AvoidBodyIntersection, + advanced); + return true; + } + + private static ModelDecimationAdvancedSettings NormalizeAdvancedSettings(ModelDecimationAdvancedSettings? settings) + { + var source = settings ?? new ModelDecimationAdvancedSettings(); + return new ModelDecimationAdvancedSettings + { + MinComponentTriangles = Math.Clamp(source.MinComponentTriangles, 0, 1000), + MaxCollapseEdgeLengthFactor = ClampFloat(source.MaxCollapseEdgeLengthFactor, 0.1f, 10f, ModelDecimationAdvancedSettings.DefaultMaxCollapseEdgeLengthFactor), + NormalSimilarityThresholdDegrees = ClampFloat(source.NormalSimilarityThresholdDegrees, 0f, 180f, ModelDecimationAdvancedSettings.DefaultNormalSimilarityThresholdDegrees), + BoneWeightSimilarityThreshold = ClampFloat(source.BoneWeightSimilarityThreshold, 0f, 1f, ModelDecimationAdvancedSettings.DefaultBoneWeightSimilarityThreshold), + UvSimilarityThreshold = ClampFloat(source.UvSimilarityThreshold, 0f, 1f, ModelDecimationAdvancedSettings.DefaultUvSimilarityThreshold), + UvSeamAngleCos = ClampFloat(source.UvSeamAngleCos, -1f, 1f, ModelDecimationAdvancedSettings.DefaultUvSeamAngleCos), + BlockUvSeamVertices = source.BlockUvSeamVertices, + AllowBoundaryCollapses = source.AllowBoundaryCollapses, + BodyCollisionDistanceFactor = ClampFloat(source.BodyCollisionDistanceFactor, 0f, 10f, ModelDecimationAdvancedSettings.DefaultBodyCollisionDistanceFactor), + BodyCollisionNoOpDistanceFactor = ClampFloat(source.BodyCollisionNoOpDistanceFactor, 0f, 10f, ModelDecimationAdvancedSettings.DefaultBodyCollisionNoOpDistanceFactor), + BodyCollisionAdaptiveRelaxFactor = ClampFloat(source.BodyCollisionAdaptiveRelaxFactor, 0f, 10f, ModelDecimationAdvancedSettings.DefaultBodyCollisionAdaptiveRelaxFactor), + BodyCollisionAdaptiveNearRatio = ClampFloat(source.BodyCollisionAdaptiveNearRatio, 0f, 1f, ModelDecimationAdvancedSettings.DefaultBodyCollisionAdaptiveNearRatio), + BodyCollisionAdaptiveUvThreshold = ClampFloat(source.BodyCollisionAdaptiveUvThreshold, 0f, 1f, ModelDecimationAdvancedSettings.DefaultBodyCollisionAdaptiveUvThreshold), + BodyCollisionNoOpUvSeamAngleCos = ClampFloat(source.BodyCollisionNoOpUvSeamAngleCos, -1f, 1f, ModelDecimationAdvancedSettings.DefaultBodyCollisionNoOpUvSeamAngleCos), + BodyCollisionProtectionFactor = ClampFloat(source.BodyCollisionProtectionFactor, 0f, 10f, ModelDecimationAdvancedSettings.DefaultBodyCollisionProtectionFactor), + BodyProxyTargetRatioMin = ClampFloat(source.BodyProxyTargetRatioMin, 0f, 1f, ModelDecimationAdvancedSettings.DefaultBodyProxyTargetRatioMin), + BodyCollisionProxyInflate = ClampFloat(source.BodyCollisionProxyInflate, 0f, 0.1f, ModelDecimationAdvancedSettings.DefaultBodyCollisionProxyInflate), + BodyCollisionPenetrationFactor = ClampFloat(source.BodyCollisionPenetrationFactor, 0f, 1f, ModelDecimationAdvancedSettings.DefaultBodyCollisionPenetrationFactor), + MinBodyCollisionDistance = ClampFloat(source.MinBodyCollisionDistance, 1e-6f, 1f, ModelDecimationAdvancedSettings.DefaultMinBodyCollisionDistance), + MinBodyCollisionCellSize = ClampFloat(source.MinBodyCollisionCellSize, 1e-6f, 1f, ModelDecimationAdvancedSettings.DefaultMinBodyCollisionCellSize), + }; + } + + private static float ClampFloat(float value, float min, float max, float fallback) + { + if (float.IsNaN(value) || float.IsInfinity(value)) + { + return fallback; + } + + return Math.Clamp(value, min, max); + } + + private bool ShouldScheduleBatchDecimation(string hash, string filePath, ModelDecimationSettings settings) + { + if (string.IsNullOrWhiteSpace(filePath) || !filePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase)) { return false; } - targetRatio = Math.Clamp(targetRatio, MinTargetRatio, MaxTargetRatio); + if (!TryNormalizeSettings(settings, out _)) + { + return false; + } return true; } diff --git a/LightlessSync/Services/ModelDecimation/ModelDecimationSettings.cs b/LightlessSync/Services/ModelDecimation/ModelDecimationSettings.cs new file mode 100644 index 0000000..4b5adc2 --- /dev/null +++ b/LightlessSync/Services/ModelDecimation/ModelDecimationSettings.cs @@ -0,0 +1,10 @@ +using LightlessSync.LightlessConfiguration.Configurations; + +namespace LightlessSync.Services.ModelDecimation; + +public readonly record struct ModelDecimationSettings( + int TriangleThreshold, + double TargetRatio, + bool NormalizeTangents, + bool AvoidBodyIntersection, + ModelDecimationAdvancedSettings Advanced); diff --git a/LightlessSync/Services/XivDataAnalyzer.cs b/LightlessSync/Services/XivDataAnalyzer.cs index 65d9346..f4b2a22 100644 --- a/LightlessSync/Services/XivDataAnalyzer.cs +++ b/LightlessSync/Services/XivDataAnalyzer.cs @@ -666,6 +666,20 @@ public sealed partial class XivDataAnalyzer return CalculateTrianglesFromPath(hash, path.ResolvedFilepath, _configService.Current.TriangleDictionary, _failedCalculatedTris); } + public long RefreshTrianglesForPath(string hash, string filePath) + { + if (string.IsNullOrEmpty(filePath) + || !filePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase) + || !File.Exists(filePath)) + { + return 0; + } + + _failedCalculatedTris.RemoveAll(entry => entry.Equals(hash, StringComparison.Ordinal)); + _configService.Current.TriangleDictionary.TryRemove(hash, out _); + return CalculateTrianglesFromPath(hash, filePath, _configService.Current.TriangleDictionary, _failedCalculatedTris); + } + public async Task GetEffectiveTrianglesByHash(string hash, string filePath) { if (_configService.Current.EffectiveTriangleDictionary.TryGetValue(hash, out var cachedTris) && cachedTris > 0) diff --git a/LightlessSync/ThirdParty/MeshDecimator/Algorithms/DecimationAlgorithm.cs b/LightlessSync/ThirdParty/MeshDecimator/Algorithms/DecimationAlgorithm.cs deleted file mode 100644 index 723eef6..0000000 --- a/LightlessSync/ThirdParty/MeshDecimator/Algorithms/DecimationAlgorithm.cs +++ /dev/null @@ -1,169 +0,0 @@ -#region License -/* -MIT License - -Copyright(c) 2017-2018 Mattias Edlund - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ -#endregion - -using System; -using Microsoft.Extensions.Logging; - -namespace MeshDecimator.Algorithms -{ - /// - /// A decimation algorithm. - /// - public abstract class DecimationAlgorithm - { - #region Delegates - /// - /// A callback for decimation status reports. - /// - /// The current iteration, starting at zero. - /// The original count of triangles. - /// The current count of triangles. - /// The target count of triangles. - public delegate void StatusReportCallback(int iteration, int originalTris, int currentTris, int targetTris); - #endregion - - #region Fields - private bool preserveBorders = false; - private int maxVertexCount = 0; - private bool verbose = false; - - private StatusReportCallback statusReportInvoker = null; - #endregion - - #region Properties - /// - /// Gets or sets if borders should be kept. - /// Default value: false - /// - [Obsolete("Use the 'DecimationAlgorithm.PreserveBorders' property instead.", false)] - public bool KeepBorders - { - get { return preserveBorders; } - set { preserveBorders = value; } - } - - /// - /// Gets or sets if borders should be preserved. - /// Default value: false - /// - public bool PreserveBorders - { - get { return preserveBorders; } - set { preserveBorders = value; } - } - - /// - /// Gets or sets if linked vertices should be kept. - /// Default value: false - /// - [Obsolete("This feature has been removed, for more details why please read the readme.", true)] - public bool KeepLinkedVertices - { - get { return false; } - set { } - } - - /// - /// Gets or sets the maximum vertex count. Set to zero for no limitation. - /// Default value: 0 (no limitation) - /// - public int MaxVertexCount - { - get { return maxVertexCount; } - set { maxVertexCount = Math.MathHelper.Max(value, 0); } - } - - /// - /// Gets or sets if verbose information should be printed in the console. - /// Default value: false - /// - public bool Verbose - { - get { return verbose; } - set { verbose = value; } - } - - /// - /// Gets or sets the logger used for diagnostics. - /// - public ILogger? Logger { get; set; } - #endregion - - #region Events - /// - /// An event for status reports for this algorithm. - /// - public event StatusReportCallback StatusReport - { - add { statusReportInvoker += value; } - remove { statusReportInvoker -= value; } - } - #endregion - - #region Protected Methods - /// - /// Reports the current status of the decimation. - /// - /// The current iteration, starting at zero. - /// The original count of triangles. - /// The current count of triangles. - /// The target count of triangles. - protected void ReportStatus(int iteration, int originalTris, int currentTris, int targetTris) - { - var statusReportInvoker = this.statusReportInvoker; - if (statusReportInvoker != null) - { - statusReportInvoker.Invoke(iteration, originalTris, currentTris, targetTris); - } - } - #endregion - - #region Public Methods - /// - /// Initializes the algorithm with the original mesh. - /// - /// The mesh. - public abstract void Initialize(Mesh mesh); - - /// - /// Decimates the mesh. - /// - /// The target triangle count. - public abstract void DecimateMesh(int targetTrisCount); - - /// - /// Decimates the mesh without losing any quality. - /// - public abstract void DecimateMeshLossless(); - - /// - /// Returns the resulting mesh. - /// - /// The resulting mesh. - public abstract Mesh ToMesh(); - #endregion - } -} diff --git a/LightlessSync/ThirdParty/MeshDecimator/Algorithms/FastQuadricMeshSimplification.cs b/LightlessSync/ThirdParty/MeshDecimator/Algorithms/FastQuadricMeshSimplification.cs deleted file mode 100644 index 31c001d..0000000 --- a/LightlessSync/ThirdParty/MeshDecimator/Algorithms/FastQuadricMeshSimplification.cs +++ /dev/null @@ -1,1627 +0,0 @@ -#region License -/* -MIT License - -Copyright(c) 2017-2018 Mattias Edlund - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ -#endregion - -#region Original License -///////////////////////////////////////////// -// -// Mesh Simplification Tutorial -// -// (C) by Sven Forstmann in 2014 -// -// License : MIT -// http://opensource.org/licenses/MIT -// -//https://github.com/sp4cerat/Fast-Quadric-Mesh-Simplification -#endregion - -using System; -using System.Collections.Generic; -using MeshDecimator.Collections; -using MeshDecimator.Math; -using Microsoft.Extensions.Logging; - -namespace MeshDecimator.Algorithms -{ - /// - /// The fast quadric mesh simplification algorithm. - /// - public sealed class FastQuadricMeshSimplification : DecimationAlgorithm - { - #region Consts - private const double DoubleEpsilon = 1.0E-3; - #endregion - - #region Classes - #region Triangle - private struct Triangle - { - #region Fields - public int v0; - public int v1; - public int v2; - public int subMeshIndex; - - public int va0; - public int va1; - public int va2; - - public double err0; - public double err1; - public double err2; - public double err3; - - public bool deleted; - public bool dirty; - public Vector3d n; - #endregion - - #region Properties - public int this[int index] - { - get - { - return (index == 0 ? v0 : (index == 1 ? v1 : v2)); - } - set - { - switch (index) - { - case 0: - v0 = value; - break; - case 1: - v1 = value; - break; - case 2: - v2 = value; - break; - default: - throw new IndexOutOfRangeException(); - } - } - } - #endregion - - #region Constructor - public Triangle(int v0, int v1, int v2, int subMeshIndex) - { - this.v0 = v0; - this.v1 = v1; - this.v2 = v2; - this.subMeshIndex = subMeshIndex; - - this.va0 = v0; - this.va1 = v1; - this.va2 = v2; - - err0 = err1 = err2 = err3 = 0; - deleted = dirty = false; - n = new Vector3d(); - } - #endregion - - #region Public Methods - public void GetAttributeIndices(int[] attributeIndices) - { - attributeIndices[0] = va0; - attributeIndices[1] = va1; - attributeIndices[2] = va2; - } - - public void SetAttributeIndex(int index, int value) - { - switch (index) - { - case 0: - va0 = value; - break; - case 1: - va1 = value; - break; - case 2: - va2 = value; - break; - default: - throw new IndexOutOfRangeException(); - } - } - - public void GetErrors(double[] err) - { - err[0] = err0; - err[1] = err1; - err[2] = err2; - } - #endregion - } - #endregion - - #region Vertex - private struct Vertex - { - public Vector3d p; - public int tstart; - public int tcount; - public SymmetricMatrix q; - public bool border; - public bool seam; - public bool foldover; - - public Vertex(Vector3d p) - { - this.p = p; - this.tstart = 0; - this.tcount = 0; - this.q = new SymmetricMatrix(); - this.border = true; - this.seam = false; - this.foldover = false; - } - } - #endregion - - #region Ref - private struct Ref - { - public int tid; - public int tvertex; - - public void Set(int tid, int tvertex) - { - this.tid = tid; - this.tvertex = tvertex; - } - } - #endregion - - #region Border Vertex - private struct BorderVertex - { - public int index; - public int hash; - - public BorderVertex(int index, int hash) - { - this.index = index; - this.hash = hash; - } - } - #endregion - - #region Border Vertex Comparer - private class BorderVertexComparer : IComparer - { - public static readonly BorderVertexComparer instance = new BorderVertexComparer(); - - public int Compare(BorderVertex x, BorderVertex y) - { - return x.hash.CompareTo(y.hash); - } - } - #endregion - #endregion - - #region Fields - private bool preserveSeams = false; - private bool preserveFoldovers = false; - private bool enableSmartLink = true; - private int maxIterationCount = 100; - private double agressiveness = 7.0; - private double vertexLinkDistanceSqr = double.Epsilon; - - private int subMeshCount = 0; - private ResizableArray triangles = null; - private ResizableArray vertices = null; - private ResizableArray refs = null; - - private ResizableArray vertNormals = null; - private ResizableArray vertTangents = null; - private ResizableArray vertTangents2 = null; - private UVChannels vertUV2D = null; - private UVChannels vertUV3D = null; - private UVChannels vertUV4D = null; - private ResizableArray vertColors = null; - private ResizableArray vertBoneWeights = null; - private ResizableArray vertPositionWs = null; - private ResizableArray vertNormalWs = null; - - private int remainingVertices = 0; - - // Pre-allocated buffers - private double[] errArr = new double[3]; - private int[] attributeIndexArr = new int[3]; - #endregion - - #region Properties - /// - /// Gets or sets if seams should be preserved. - /// Default value: false - /// - public bool PreserveSeams - { - get { return preserveSeams; } - set { preserveSeams = value; } - } - - /// - /// Gets or sets if foldovers should be preserved. - /// Default value: false - /// - public bool PreserveFoldovers - { - get { return preserveFoldovers; } - set { preserveFoldovers = value; } - } - - /// - /// Gets or sets if a feature for smarter vertex linking should be enabled, reducing artifacts in the - /// decimated result at the cost of a slightly more expensive initialization by treating vertices at - /// the same position as the same vertex while separating the attributes. - /// Default value: true - /// - public bool EnableSmartLink - { - get { return enableSmartLink; } - set { enableSmartLink = value; } - } - - /// - /// Gets or sets the maximum iteration count. Higher number is more expensive but can bring you closer to your target quality. - /// Sometimes a lower maximum count might be desired in order to lower the performance cost. - /// Default value: 100 - /// - public int MaxIterationCount - { - get { return maxIterationCount; } - set { maxIterationCount = value; } - } - - /// - /// Gets or sets the agressiveness of this algorithm. Higher number equals higher quality, but more expensive to run. - /// Default value: 7.0 - /// - public double Agressiveness - { - get { return agressiveness; } - set { agressiveness = value; } - } - - /// - /// Gets or sets the maximum squared distance between two vertices in order to link them. - /// Note that this value is only used if EnableSmartLink is true. - /// Default value: double.Epsilon - /// - public double VertexLinkDistanceSqr - { - get { return vertexLinkDistanceSqr; } - set { vertexLinkDistanceSqr = value; } - } - #endregion - - #region Constructor - /// - /// Creates a new fast quadric mesh simplification algorithm. - /// - public FastQuadricMeshSimplification() - { - triangles = new ResizableArray(0); - vertices = new ResizableArray(0); - refs = new ResizableArray(0); - } - #endregion - - #region Private Methods - #region Initialize Vertex Attribute - private ResizableArray InitializeVertexAttribute(T[] attributeValues, string attributeName) - { - if (attributeValues != null && attributeValues.Length == vertices.Length) - { - var newArray = new ResizableArray(attributeValues.Length, attributeValues.Length); - var newArrayData = newArray.Data; - Array.Copy(attributeValues, 0, newArrayData, 0, attributeValues.Length); - return newArray; - } - else if (attributeValues != null && attributeValues.Length > 0) - { - Logger?.LogError( - "Failed to set vertex attribute '{Attribute}' with {ActualLength} length of array, when {ExpectedLength} was needed.", - attributeName, - attributeValues.Length, - vertices.Length); - } - return null; - } - #endregion - - #region Calculate Error - private double VertexError(ref SymmetricMatrix q, double x, double y, double z) - { - return q.m0*x*x + 2*q.m1*x*y + 2*q.m2*x*z + 2*q.m3*x + q.m4*y*y - + 2*q.m5*y*z + 2*q.m6*y + q.m7*z*z + 2*q.m8*z + q.m9; - } - - private double CalculateError(ref Vertex vert0, ref Vertex vert1, out Vector3d result, out int resultIndex) - { - // compute interpolated vertex - SymmetricMatrix q = (vert0.q + vert1.q); - bool border = (vert0.border & vert1.border); - double error = 0.0; - double det = q.Determinant1(); - if (det != 0.0 && !border) - { - // q_delta is invertible - result = new Vector3d( - -1.0 / det * q.Determinant2(), // vx = A41/det(q_delta) - 1.0 / det * q.Determinant3(), // vy = A42/det(q_delta) - -1.0 / det * q.Determinant4()); // vz = A43/det(q_delta) - error = VertexError(ref q, result.x, result.y, result.z); - resultIndex = 2; - } - else - { - // det = 0 -> try to find best result - Vector3d p1 = vert0.p; - Vector3d p2 = vert1.p; - Vector3d p3 = (p1 + p2) * 0.5f; - double error1 = VertexError(ref q, p1.x, p1.y, p1.z); - double error2 = VertexError(ref q, p2.x, p2.y, p2.z); - double error3 = VertexError(ref q, p3.x, p3.y, p3.z); - error = MathHelper.Min(error1, error2, error3); - if (error == error3) - { - result = p3; - resultIndex = 2; - } - else if (error == error2) - { - result = p2; - resultIndex = 1; - } - else if (error == error1) - { - result = p1; - resultIndex = 0; - } - else - { - result = p3; - resultIndex = 2; - } - } - return error; - } - #endregion - - #region Flipped - /// - /// Check if a triangle flips when this edge is removed - /// - private bool Flipped(ref Vector3d p, int i0, int i1, ref Vertex v0, bool[] deleted) - { - int tcount = v0.tcount; - var refs = this.refs.Data; - var triangles = this.triangles.Data; - var vertices = this.vertices.Data; - for (int k = 0; k < tcount; k++) - { - Ref r = refs[v0.tstart + k]; - if (triangles[r.tid].deleted) - continue; - - int s = r.tvertex; - int id1 = triangles[r.tid][(s + 1) % 3]; - int id2 = triangles[r.tid][(s + 2) % 3]; - if (id1 == i1 || id2 == i1) - { - deleted[k] = true; - continue; - } - - Vector3d d1 = vertices[id1].p - p; - d1.Normalize(); - Vector3d d2 = vertices[id2].p - p; - d2.Normalize(); - double dot = Vector3d.Dot(ref d1, ref d2); - if (System.Math.Abs(dot) > 0.999) - return true; - - Vector3d n; - Vector3d.Cross(ref d1, ref d2, out n); - n.Normalize(); - deleted[k] = false; - dot = Vector3d.Dot(ref n, ref triangles[r.tid].n); - if (dot < 0.2) - return true; - } - - return false; - } - #endregion - - #region Update Triangles - /// - /// Update triangle connections and edge error after a edge is collapsed. - /// - private void UpdateTriangles(int i0, int ia0, ref Vertex v, ResizableArray deleted, ref int deletedTriangles) - { - Vector3d p; - int pIndex; - int tcount = v.tcount; - var triangles = this.triangles.Data; - var vertices = this.vertices.Data; - for (int k = 0; k < tcount; k++) - { - Ref r = refs[v.tstart + k]; - int tid = r.tid; - Triangle t = triangles[tid]; - if (t.deleted) - continue; - - if (deleted[k]) - { - triangles[tid].deleted = true; - ++deletedTriangles; - continue; - } - - t[r.tvertex] = i0; - if (ia0 != -1) - { - t.SetAttributeIndex(r.tvertex, ia0); - } - - t.dirty = true; - t.err0 = CalculateError(ref vertices[t.v0], ref vertices[t.v1], out p, out pIndex); - t.err1 = CalculateError(ref vertices[t.v1], ref vertices[t.v2], out p, out pIndex); - t.err2 = CalculateError(ref vertices[t.v2], ref vertices[t.v0], out p, out pIndex); - t.err3 = MathHelper.Min(t.err0, t.err1, t.err2); - triangles[tid] = t; - refs.Add(r); - } - } - #endregion - - #region Move/Merge Vertex Attributes - private void MoveVertexAttributes(int i0, int i1) - { - if (vertNormals != null) - { - vertNormals[i0] = vertNormals[i1]; - } - if (vertPositionWs != null) - { - vertPositionWs[i0] = vertPositionWs[i1]; - } - if (vertNormalWs != null) - { - vertNormalWs[i0] = vertNormalWs[i1]; - } - if (vertTangents != null) - { - vertTangents[i0] = vertTangents[i1]; - } - if (vertTangents2 != null) - { - vertTangents2[i0] = vertTangents2[i1]; - } - if (vertUV2D != null) - { - for (int i = 0; i < Mesh.UVChannelCount; i++) - { - var vertUV = vertUV2D[i]; - if (vertUV != null) - { - vertUV[i0] = vertUV[i1]; - } - } - } - if (vertUV3D != null) - { - for (int i = 0; i < Mesh.UVChannelCount; i++) - { - var vertUV = vertUV3D[i]; - if (vertUV != null) - { - vertUV[i0] = vertUV[i1]; - } - } - } - if (vertUV4D != null) - { - for (int i = 0; i < Mesh.UVChannelCount; i++) - { - var vertUV = vertUV4D[i]; - if (vertUV != null) - { - vertUV[i0] = vertUV[i1]; - } - } - } - if (vertColors != null) - { - vertColors[i0] = vertColors[i1]; - } - if (vertBoneWeights != null) - { - vertBoneWeights[i0] = vertBoneWeights[i1]; - } - } - - private void MergeVertexAttributes(int i0, int i1) - { - if (vertNormals != null) - { - vertNormals[i0] = (vertNormals[i0] + vertNormals[i1]) * 0.5f; - } - if (vertPositionWs != null) - { - vertPositionWs[i0] = (vertPositionWs[i0] + vertPositionWs[i1]) * 0.5f; - } - if (vertNormalWs != null) - { - vertNormalWs[i0] = (vertNormalWs[i0] + vertNormalWs[i1]) * 0.5f; - } - if (vertTangents != null) - { - vertTangents[i0] = (vertTangents[i0] + vertTangents[i1]) * 0.5f; - } - if (vertTangents2 != null) - { - vertTangents2[i0] = (vertTangents2[i0] + vertTangents2[i1]) * 0.5f; - } - if (vertUV2D != null) - { - for (int i = 0; i < Mesh.UVChannelCount; i++) - { - var vertUV = vertUV2D[i]; - if (vertUV != null) - { - vertUV[i0] = (vertUV[i0] + vertUV[i1]) * 0.5f; - } - } - } - if (vertUV3D != null) - { - for (int i = 0; i < Mesh.UVChannelCount; i++) - { - var vertUV = vertUV3D[i]; - if (vertUV != null) - { - vertUV[i0] = (vertUV[i0] + vertUV[i1]) * 0.5f; - } - } - } - if (vertUV4D != null) - { - for (int i = 0; i < Mesh.UVChannelCount; i++) - { - var vertUV = vertUV4D[i]; - if (vertUV != null) - { - vertUV[i0] = (vertUV[i0] + vertUV[i1]) * 0.5f; - } - } - } - if (vertColors != null) - { - vertColors[i0] = (vertColors[i0] + vertColors[i1]) * 0.5f; - } - - // TODO: Do we have to blend bone weights at all or can we just keep them as it is in this scenario? - } - #endregion - - #region Are UVs The Same - private bool AreUVsTheSame(int channel, int indexA, int indexB) - { - if (vertUV2D != null) - { - var vertUV = vertUV2D[channel]; - if (vertUV != null) - { - var uvA = vertUV[indexA]; - var uvB = vertUV[indexB]; - return uvA == uvB; - } - } - - if (vertUV3D != null) - { - var vertUV = vertUV3D[channel]; - if (vertUV != null) - { - var uvA = vertUV[indexA]; - var uvB = vertUV[indexB]; - return uvA == uvB; - } - } - - if (vertUV4D != null) - { - var vertUV = vertUV4D[channel]; - if (vertUV != null) - { - var uvA = vertUV[indexA]; - var uvB = vertUV[indexB]; - return uvA == uvB; - } - } - - return false; - } - #endregion - - #region Remove Vertex Pass - /// - /// Remove vertices and mark deleted triangles - /// - private void RemoveVertexPass(int startTrisCount, int targetTrisCount, double threshold, ResizableArray deleted0, ResizableArray deleted1, ref int deletedTris) - { - var triangles = this.triangles.Data; - int triangleCount = this.triangles.Length; - var vertices = this.vertices.Data; - - bool preserveBorders = base.PreserveBorders; - int maxVertexCount = base.MaxVertexCount; - if (maxVertexCount <= 0) - maxVertexCount = int.MaxValue; - - Vector3d p; - int pIndex; - for (int tid = 0; tid < triangleCount; tid++) - { - if (triangles[tid].dirty || triangles[tid].deleted || triangles[tid].err3 > threshold) - continue; - - triangles[tid].GetErrors(errArr); - triangles[tid].GetAttributeIndices(attributeIndexArr); - for (int edgeIndex = 0; edgeIndex < 3; edgeIndex++) - { - if (errArr[edgeIndex] > threshold) - continue; - - int nextEdgeIndex = ((edgeIndex + 1) % 3); - int i0 = triangles[tid][edgeIndex]; - int i1 = triangles[tid][nextEdgeIndex]; - - // Border check - if (vertices[i0].border != vertices[i1].border) - continue; - // Seam check - else if (vertices[i0].seam != vertices[i1].seam) - continue; - // Foldover check - else if (vertices[i0].foldover != vertices[i1].foldover) - continue; - // If borders should be preserved - else if (preserveBorders && vertices[i0].border) - continue; - // If seams should be preserved - else if (preserveSeams && vertices[i0].seam) - continue; - // If foldovers should be preserved - else if (preserveFoldovers && vertices[i0].foldover) - continue; - - // Compute vertex to collapse to - CalculateError(ref vertices[i0], ref vertices[i1], out p, out pIndex); - deleted0.Resize(vertices[i0].tcount); // normals temporarily - deleted1.Resize(vertices[i1].tcount); // normals temporarily - - // Don't remove if flipped - if (Flipped(ref p, i0, i1, ref vertices[i0], deleted0.Data)) - continue; - if (Flipped(ref p, i1, i0, ref vertices[i1], deleted1.Data)) - continue; - - int ia0 = attributeIndexArr[edgeIndex]; - - // Not flipped, so remove edge - vertices[i0].p = p; - vertices[i0].q += vertices[i1].q; - - if (pIndex == 1) - { - // Move vertex attributes from ia1 to ia0 - int ia1 = attributeIndexArr[nextEdgeIndex]; - MoveVertexAttributes(ia0, ia1); - } - else if (pIndex == 2) - { - // Merge vertex attributes ia0 and ia1 into ia0 - int ia1 = attributeIndexArr[nextEdgeIndex]; - MergeVertexAttributes(ia0, ia1); - } - - if (vertices[i0].seam) - { - ia0 = -1; - } - - int tstart = refs.Length; - UpdateTriangles(i0, ia0, ref vertices[i0], deleted0, ref deletedTris); - UpdateTriangles(i0, ia0, ref vertices[i1], deleted1, ref deletedTris); - - int tcount = refs.Length - tstart; - if (tcount <= vertices[i0].tcount) - { - // save ram - if (tcount > 0) - { - var refsArr = refs.Data; - Array.Copy(refsArr, tstart, refsArr, vertices[i0].tstart, tcount); - } - } - else - { - // append - vertices[i0].tstart = tstart; - } - - vertices[i0].tcount = tcount; - --remainingVertices; - break; - } - - // Check if we are already done - if ((startTrisCount - deletedTris) <= targetTrisCount && remainingVertices < maxVertexCount) - break; - } - } - #endregion - - #region Update Mesh - /// - /// Compact triangles, compute edge error and build reference list. - /// - /// The iteration index. - private void UpdateMesh(int iteration) - { - var triangles = this.triangles.Data; - var vertices = this.vertices.Data; - - int triangleCount = this.triangles.Length; - int vertexCount = this.vertices.Length; - if (iteration > 0) // compact triangles - { - int dst = 0; - for (int i = 0; i < triangleCount; i++) - { - if (!triangles[i].deleted) - { - if (dst != i) - { - triangles[dst] = triangles[i]; - } - dst++; - } - } - this.triangles.Resize(dst); - triangles = this.triangles.Data; - triangleCount = dst; - } - - UpdateReferences(); - - // Identify boundary : vertices[].border=0,1 - if (iteration == 0) - { - var refs = this.refs.Data; - - var vcount = new List(8); - var vids = new List(8); - int vsize = 0; - for (int i = 0; i < vertexCount; i++) - { - vertices[i].border = false; - vertices[i].seam = false; - vertices[i].foldover = false; - } - - int ofs; - int id; - int borderVertexCount = 0; - double borderMinX = double.MaxValue; - double borderMaxX = double.MinValue; - for (int i = 0; i < vertexCount; i++) - { - int tstart = vertices[i].tstart; - int tcount = vertices[i].tcount; - vcount.Clear(); - vids.Clear(); - vsize = 0; - - for (int j = 0; j < tcount; j++) - { - int tid = refs[tstart + j].tid; - for (int k = 0; k < 3; k++) - { - ofs = 0; - id = triangles[tid][k]; - while (ofs < vsize) - { - if (vids[ofs] == id) - break; - - ++ofs; - } - - if (ofs == vsize) - { - vcount.Add(1); - vids.Add(id); - ++vsize; - } - else - { - ++vcount[ofs]; - } - } - } - - for (int j = 0; j < vsize; j++) - { - if (vcount[j] == 1) - { - id = vids[j]; - vertices[id].border = true; - ++borderVertexCount; - - if (enableSmartLink) - { - if (vertices[id].p.x < borderMinX) - { - borderMinX = vertices[id].p.x; - } - if (vertices[id].p.x > borderMaxX) - { - borderMaxX = vertices[id].p.x; - } - } - } - } - } - - if (enableSmartLink) - { - // First find all border vertices - var borderVertices = new BorderVertex[borderVertexCount]; - int borderIndexCount = 0; - double borderAreaWidth = borderMaxX - borderMinX; - for (int i = 0; i < vertexCount; i++) - { - if (vertices[i].border) - { - int vertexHash = (int)(((((vertices[i].p.x - borderMinX) / borderAreaWidth) * 2.0) - 1.0) * int.MaxValue); - borderVertices[borderIndexCount] = new BorderVertex(i, vertexHash); - ++borderIndexCount; - } - } - - // Sort the border vertices by hash - Array.Sort(borderVertices, 0, borderIndexCount, BorderVertexComparer.instance); - - // Calculate the maximum hash distance based on the maximum vertex link distance - double vertexLinkDistance = System.Math.Sqrt(vertexLinkDistanceSqr); - int hashMaxDistance = System.Math.Max((int)((vertexLinkDistance / borderAreaWidth) * int.MaxValue), 1); - - // Then find identical border vertices and bind them together as one - for (int i = 0; i < borderIndexCount; i++) - { - int myIndex = borderVertices[i].index; - if (myIndex == -1) - continue; - - var myPoint = vertices[myIndex].p; - for (int j = i + 1; j < borderIndexCount; j++) - { - int otherIndex = borderVertices[j].index; - if (otherIndex == -1) - continue; - else if ((borderVertices[j].hash - borderVertices[i].hash) > hashMaxDistance) // There is no point to continue beyond this point - break; - - var otherPoint = vertices[otherIndex].p; - var sqrX = ((myPoint.x - otherPoint.x) * (myPoint.x - otherPoint.x)); - var sqrY = ((myPoint.y - otherPoint.y) * (myPoint.y - otherPoint.y)); - var sqrZ = ((myPoint.z - otherPoint.z) * (myPoint.z - otherPoint.z)); - var sqrMagnitude = sqrX + sqrY + sqrZ; - - if (sqrMagnitude <= vertexLinkDistanceSqr) - { - borderVertices[j].index = -1; // NOTE: This makes sure that the "other" vertex is not processed again - vertices[myIndex].border = false; - vertices[otherIndex].border = false; - - if (AreUVsTheSame(0, myIndex, otherIndex)) - { - vertices[myIndex].foldover = true; - vertices[otherIndex].foldover = true; - } - else - { - vertices[myIndex].seam = true; - vertices[otherIndex].seam = true; - } - - int otherTriangleCount = vertices[otherIndex].tcount; - int otherTriangleStart = vertices[otherIndex].tstart; - for (int k = 0; k < otherTriangleCount; k++) - { - var r = refs[otherTriangleStart + k]; - triangles[r.tid][r.tvertex] = myIndex; - } - } - } - } - - // Update the references again - UpdateReferences(); - } - - // Init Quadrics by Plane & Edge Errors - // - // required at the beginning ( iteration == 0 ) - // recomputing during the simplification is not required, - // but mostly improves the result for closed meshes - for (int i = 0; i < vertexCount; i++) - { - vertices[i].q = new SymmetricMatrix(); - } - - int v0, v1, v2; - Vector3d n, p0, p1, p2, p10, p20, dummy; - int dummy2; - SymmetricMatrix sm; - for (int i = 0; i < triangleCount; i++) - { - v0 = triangles[i].v0; - v1 = triangles[i].v1; - v2 = triangles[i].v2; - - p0 = vertices[v0].p; - p1 = vertices[v1].p; - p2 = vertices[v2].p; - p10 = p1 - p0; - p20 = p2 - p0; - Vector3d.Cross(ref p10, ref p20, out n); - n.Normalize(); - triangles[i].n = n; - - sm = new SymmetricMatrix(n.x, n.y, n.z, -Vector3d.Dot(ref n, ref p0)); - vertices[v0].q += sm; - vertices[v1].q += sm; - vertices[v2].q += sm; - } - - for (int i = 0; i < triangleCount; i++) - { - // Calc Edge Error - var triangle = triangles[i]; - triangles[i].err0 = CalculateError(ref vertices[triangle.v0], ref vertices[triangle.v1], out dummy, out dummy2); - triangles[i].err1 = CalculateError(ref vertices[triangle.v1], ref vertices[triangle.v2], out dummy, out dummy2); - triangles[i].err2 = CalculateError(ref vertices[triangle.v2], ref vertices[triangle.v0], out dummy, out dummy2); - triangles[i].err3 = MathHelper.Min(triangles[i].err0, triangles[i].err1, triangles[i].err2); - } - } - } - #endregion - - #region Update References - private void UpdateReferences() - { - int triangleCount = this.triangles.Length; - int vertexCount = this.vertices.Length; - var triangles = this.triangles.Data; - var vertices = this.vertices.Data; - - // Init Reference ID list - for (int i = 0; i < vertexCount; i++) - { - vertices[i].tstart = 0; - vertices[i].tcount = 0; - } - - for (int i = 0; i < triangleCount; i++) - { - ++vertices[triangles[i].v0].tcount; - ++vertices[triangles[i].v1].tcount; - ++vertices[triangles[i].v2].tcount; - } - - int tstart = 0; - remainingVertices = 0; - for (int i = 0; i < vertexCount; i++) - { - vertices[i].tstart = tstart; - if (vertices[i].tcount > 0) - { - tstart += vertices[i].tcount; - vertices[i].tcount = 0; - ++remainingVertices; - } - } - - // Write References - this.refs.Resize(tstart); - var refs = this.refs.Data; - for (int i = 0; i < triangleCount; i++) - { - int v0 = triangles[i].v0; - int v1 = triangles[i].v1; - int v2 = triangles[i].v2; - int start0 = vertices[v0].tstart; - int count0 = vertices[v0].tcount; - int start1 = vertices[v1].tstart; - int count1 = vertices[v1].tcount; - int start2 = vertices[v2].tstart; - int count2 = vertices[v2].tcount; - - refs[start0 + count0].Set(i, 0); - refs[start1 + count1].Set(i, 1); - refs[start2 + count2].Set(i, 2); - - ++vertices[v0].tcount; - ++vertices[v1].tcount; - ++vertices[v2].tcount; - } - } - #endregion - - #region Compact Mesh - /// - /// Finally compact mesh before exiting. - /// - private void CompactMesh() - { - int dst = 0; - var vertices = this.vertices.Data; - int vertexCount = this.vertices.Length; - for (int i = 0; i < vertexCount; i++) - { - vertices[i].tcount = 0; - } - - var vertNormals = (this.vertNormals != null ? this.vertNormals.Data : null); - var vertTangents = (this.vertTangents != null ? this.vertTangents.Data : null); - var vertTangents2 = (this.vertTangents2 != null ? this.vertTangents2.Data : null); - var vertUV2D = (this.vertUV2D != null ? this.vertUV2D.Data : null); - var vertUV3D = (this.vertUV3D != null ? this.vertUV3D.Data : null); - var vertUV4D = (this.vertUV4D != null ? this.vertUV4D.Data : null); - var vertColors = (this.vertColors != null ? this.vertColors.Data : null); - var vertBoneWeights = (this.vertBoneWeights != null ? this.vertBoneWeights.Data : null); - var vertPositionWs = (this.vertPositionWs != null ? this.vertPositionWs.Data : null); - var vertNormalWs = (this.vertNormalWs != null ? this.vertNormalWs.Data : null); - - var triangles = this.triangles.Data; - int triangleCount = this.triangles.Length; - for (int i = 0; i < triangleCount; i++) - { - var triangle = triangles[i]; - if (!triangle.deleted) - { - if (triangle.va0 != triangle.v0) - { - int iDest = triangle.va0; - int iSrc = triangle.v0; - vertices[iDest].p = vertices[iSrc].p; - if (vertBoneWeights != null) - { - vertBoneWeights[iDest] = vertBoneWeights[iSrc]; - } - if (vertPositionWs != null) - { - vertPositionWs[iDest] = vertPositionWs[iSrc]; - } - if (vertNormalWs != null) - { - vertNormalWs[iDest] = vertNormalWs[iSrc]; - } - triangle.v0 = triangle.va0; - } - if (triangle.va1 != triangle.v1) - { - int iDest = triangle.va1; - int iSrc = triangle.v1; - vertices[iDest].p = vertices[iSrc].p; - if (vertBoneWeights != null) - { - vertBoneWeights[iDest] = vertBoneWeights[iSrc]; - } - if (vertPositionWs != null) - { - vertPositionWs[iDest] = vertPositionWs[iSrc]; - } - if (vertNormalWs != null) - { - vertNormalWs[iDest] = vertNormalWs[iSrc]; - } - triangle.v1 = triangle.va1; - } - if (triangle.va2 != triangle.v2) - { - int iDest = triangle.va2; - int iSrc = triangle.v2; - vertices[iDest].p = vertices[iSrc].p; - if (vertBoneWeights != null) - { - vertBoneWeights[iDest] = vertBoneWeights[iSrc]; - } - if (vertPositionWs != null) - { - vertPositionWs[iDest] = vertPositionWs[iSrc]; - } - if (vertNormalWs != null) - { - vertNormalWs[iDest] = vertNormalWs[iSrc]; - } - triangle.v2 = triangle.va2; - } - - triangles[dst++] = triangle; - - vertices[triangle.v0].tcount = 1; - vertices[triangle.v1].tcount = 1; - vertices[triangle.v2].tcount = 1; - } - } - - triangleCount = dst; - this.triangles.Resize(triangleCount); - triangles = this.triangles.Data; - - dst = 0; - for (int i = 0; i < vertexCount; i++) - { - var vert = vertices[i]; - if (vert.tcount > 0) - { - vert.tstart = dst; - vertices[i] = vert; - - if (dst != i) - { - vertices[dst].p = vert.p; - if (vertNormals != null) vertNormals[dst] = vertNormals[i]; - if (vertTangents != null) vertTangents[dst] = vertTangents[i]; - if (vertTangents2 != null) vertTangents2[dst] = vertTangents2[i]; - if (vertUV2D != null) - { - for (int j = 0; j < Mesh.UVChannelCount; j++) - { - var vertUV = vertUV2D[j]; - if (vertUV != null) - { - vertUV[dst] = vertUV[i]; - } - } - } - if (vertUV3D != null) - { - for (int j = 0; j < Mesh.UVChannelCount; j++) - { - var vertUV = vertUV3D[j]; - if (vertUV != null) - { - vertUV[dst] = vertUV[i]; - } - } - } - if (vertUV4D != null) - { - for (int j = 0; j < Mesh.UVChannelCount; j++) - { - var vertUV = vertUV4D[j]; - if (vertUV != null) - { - vertUV[dst] = vertUV[i]; - } - } - } - if (vertColors != null) vertColors[dst] = vertColors[i]; - if (vertBoneWeights != null) vertBoneWeights[dst] = vertBoneWeights[i]; - if (vertPositionWs != null) vertPositionWs[dst] = vertPositionWs[i]; - if (vertNormalWs != null) vertNormalWs[dst] = vertNormalWs[i]; - } - ++dst; - } - } - - for (int i = 0; i < triangleCount; i++) - { - var triangle = triangles[i]; - triangle.v0 = vertices[triangle.v0].tstart; - triangle.v1 = vertices[triangle.v1].tstart; - triangle.v2 = vertices[triangle.v2].tstart; - triangles[i] = triangle; - } - - vertexCount = dst; - this.vertices.Resize(vertexCount); - if (vertNormals != null) this.vertNormals.Resize(vertexCount, true); - if (vertTangents != null) this.vertTangents.Resize(vertexCount, true); - if (vertTangents2 != null) this.vertTangents2.Resize(vertexCount, true); - if (vertUV2D != null) this.vertUV2D.Resize(vertexCount, true); - if (vertUV3D != null) this.vertUV3D.Resize(vertexCount, true); - if (vertUV4D != null) this.vertUV4D.Resize(vertexCount, true); - if (vertColors != null) this.vertColors.Resize(vertexCount, true); - if (vertBoneWeights != null) this.vertBoneWeights.Resize(vertexCount, true); - if (vertPositionWs != null) this.vertPositionWs.Resize(vertexCount, true); - if (vertNormalWs != null) this.vertNormalWs.Resize(vertexCount, true); - } - #endregion - #endregion - - #region Public Methods - #region Initialize - /// - /// Initializes the algorithm with the original mesh. - /// - /// The mesh. - public override void Initialize(Mesh mesh) - { - if (mesh == null) - throw new ArgumentNullException("mesh"); - - int meshSubMeshCount = mesh.SubMeshCount; - int meshTriangleCount = mesh.TriangleCount; - var meshVertices = mesh.Vertices; - var meshNormals = mesh.Normals; - var meshPositionWs = mesh.PositionWs; - var meshNormalWs = mesh.NormalWs; - var meshTangents = mesh.Tangents; - var meshTangents2 = mesh.Tangents2; - var meshColors = mesh.Colors; - var meshBoneWeights = mesh.BoneWeights; - subMeshCount = meshSubMeshCount; - - vertices.Resize(meshVertices.Length); - var vertArr = vertices.Data; - for (int i = 0; i < meshVertices.Length; i++) - { - vertArr[i] = new Vertex(meshVertices[i]); - } - - triangles.Resize(meshTriangleCount); - var trisArr = triangles.Data; - int triangleIndex = 0; - for (int subMeshIndex = 0; subMeshIndex < meshSubMeshCount; subMeshIndex++) - { - int[] subMeshIndices = mesh.GetIndices(subMeshIndex); - int subMeshTriangleCount = subMeshIndices.Length / 3; - for (int i = 0; i < subMeshTriangleCount; i++) - { - int offset = i * 3; - int v0 = subMeshIndices[offset]; - int v1 = subMeshIndices[offset + 1]; - int v2 = subMeshIndices[offset + 2]; - trisArr[triangleIndex++] = new Triangle(v0, v1, v2, subMeshIndex); - } - } - - vertNormals = InitializeVertexAttribute(meshNormals, "normals"); - vertPositionWs = InitializeVertexAttribute(meshPositionWs, "positionWs"); - vertNormalWs = InitializeVertexAttribute(meshNormalWs, "normalWs"); - vertTangents = InitializeVertexAttribute(meshTangents, "tangents"); - vertTangents2 = InitializeVertexAttribute(meshTangents2, "tangents2"); - vertColors = InitializeVertexAttribute(meshColors, "colors"); - vertBoneWeights = InitializeVertexAttribute(meshBoneWeights, "boneWeights"); - - for (int i = 0; i < Mesh.UVChannelCount; i++) - { - int uvDim = mesh.GetUVDimension(i); - string uvAttributeName = string.Format("uv{0}", i); - if (uvDim == 2) - { - if (vertUV2D == null) - vertUV2D = new UVChannels(); - - var uvs = mesh.GetUVs2D(i); - vertUV2D[i] = InitializeVertexAttribute(uvs, uvAttributeName); - } - else if (uvDim == 3) - { - if (vertUV3D == null) - vertUV3D = new UVChannels(); - - var uvs = mesh.GetUVs3D(i); - vertUV3D[i] = InitializeVertexAttribute(uvs, uvAttributeName); - } - else if (uvDim == 4) - { - if (vertUV4D == null) - vertUV4D = new UVChannels(); - - var uvs = mesh.GetUVs4D(i); - vertUV4D[i] = InitializeVertexAttribute(uvs, uvAttributeName); - } - } - } - #endregion - - #region Decimate Mesh - /// - /// Decimates the mesh. - /// - /// The target triangle count. - public override void DecimateMesh(int targetTrisCount) - { - if (targetTrisCount < 0) - throw new ArgumentOutOfRangeException("targetTrisCount"); - - int deletedTris = 0; - ResizableArray deleted0 = new ResizableArray(20); - ResizableArray deleted1 = new ResizableArray(20); - var triangles = this.triangles.Data; - int triangleCount = this.triangles.Length; - int startTrisCount = triangleCount; - var vertices = this.vertices.Data; - - int maxVertexCount = base.MaxVertexCount; - if (maxVertexCount <= 0) - maxVertexCount = int.MaxValue; - - for (int iteration = 0; iteration < maxIterationCount; iteration++) - { - ReportStatus(iteration, startTrisCount, (startTrisCount - deletedTris), targetTrisCount); - if ((startTrisCount - deletedTris) <= targetTrisCount && remainingVertices < maxVertexCount) - break; - - // Update mesh once in a while - if ((iteration % 5) == 0) - { - UpdateMesh(iteration); - triangles = this.triangles.Data; - triangleCount = this.triangles.Length; - vertices = this.vertices.Data; - } - - // Clear dirty flag - for (int i = 0; i < triangleCount; i++) - { - triangles[i].dirty = false; - } - - // All triangles with edges below the threshold will be removed - // - // The following numbers works well for most models. - // If it does not, try to adjust the 3 parameters - double threshold = 0.000000001 * System.Math.Pow(iteration + 3, agressiveness); - - if (Verbose && (iteration % 5) == 0) - { - Logger?.LogTrace( - "Iteration {Iteration} - triangles {Triangles} threshold {Threshold}", - iteration, - (startTrisCount - deletedTris), - threshold); - } - - // Remove vertices & mark deleted triangles - RemoveVertexPass(startTrisCount, targetTrisCount, threshold, deleted0, deleted1, ref deletedTris); - } - - CompactMesh(); - } - #endregion - - #region Decimate Mesh Lossless - /// - /// Decimates the mesh without losing any quality. - /// - public override void DecimateMeshLossless() - { - int deletedTris = 0; - ResizableArray deleted0 = new ResizableArray(0); - ResizableArray deleted1 = new ResizableArray(0); - var triangles = this.triangles.Data; - int triangleCount = this.triangles.Length; - int startTrisCount = triangleCount; - var vertices = this.vertices.Data; - - ReportStatus(0, startTrisCount, startTrisCount, -1); - for (int iteration = 0; iteration < 9999; iteration++) - { - // Update mesh constantly - UpdateMesh(iteration); - triangles = this.triangles.Data; - triangleCount = this.triangles.Length; - vertices = this.vertices.Data; - - ReportStatus(iteration, startTrisCount, triangleCount, -1); - - // Clear dirty flag - for (int i = 0; i < triangleCount; i++) - { - triangles[i].dirty = false; - } - - // All triangles with edges below the threshold will be removed - // - // The following numbers works well for most models. - // If it does not, try to adjust the 3 parameters - double threshold = DoubleEpsilon; - - if (Verbose) - { - Logger?.LogTrace("Lossless iteration {Iteration}", iteration); - } - - // Remove vertices & mark deleted triangles - RemoveVertexPass(startTrisCount, 0, threshold, deleted0, deleted1, ref deletedTris); - - if (deletedTris <= 0) - break; - - deletedTris = 0; - } - - CompactMesh(); - } - #endregion - - #region To Mesh - /// - /// Returns the resulting mesh. - /// - /// The resulting mesh. - public override Mesh ToMesh() - { - int vertexCount = this.vertices.Length; - int triangleCount = this.triangles.Length; - var vertices = new Vector3d[vertexCount]; - var indices = new int[subMeshCount][]; - - var vertArr = this.vertices.Data; - for (int i = 0; i < vertexCount; i++) - { - vertices[i] = vertArr[i].p; - } - - // First get the sub-mesh offsets - var triArr = this.triangles.Data; - int[] subMeshOffsets = new int[subMeshCount]; - int lastSubMeshOffset = -1; - for (int i = 0; i < triangleCount; i++) - { - var triangle = triArr[i]; - if (triangle.subMeshIndex != lastSubMeshOffset) - { - for (int j = lastSubMeshOffset + 1; j < triangle.subMeshIndex; j++) - { - subMeshOffsets[j] = i; - } - subMeshOffsets[triangle.subMeshIndex] = i; - lastSubMeshOffset = triangle.subMeshIndex; - } - } - for (int i = lastSubMeshOffset + 1; i < subMeshCount; i++) - { - subMeshOffsets[i] = triangleCount; - } - - // Then setup the sub-meshes - for (int subMeshIndex = 0; subMeshIndex < subMeshCount; subMeshIndex++) - { - int startOffset = subMeshOffsets[subMeshIndex]; - if (startOffset < triangleCount) - { - int endOffset = ((subMeshIndex + 1) < subMeshCount ? subMeshOffsets[subMeshIndex + 1] : triangleCount); - int subMeshTriangleCount = endOffset - startOffset; - if (subMeshTriangleCount < 0) subMeshTriangleCount = 0; - int[] subMeshIndices = new int[subMeshTriangleCount * 3]; - - for (int triangleIndex = startOffset; triangleIndex < endOffset; triangleIndex++) - { - var triangle = triArr[triangleIndex]; - int offset = (triangleIndex - startOffset) * 3; - subMeshIndices[offset] = triangle.v0; - subMeshIndices[offset + 1] = triangle.v1; - subMeshIndices[offset + 2] = triangle.v2; - } - - indices[subMeshIndex] = subMeshIndices; - } - else - { - // This mesh doesn't have any triangles left - indices[subMeshIndex] = new int[0]; - } - } - - Mesh newMesh = new Mesh(vertices, indices); - - if (vertNormals != null) - { - newMesh.Normals = vertNormals.Data; - } - if (vertPositionWs != null) - { - newMesh.PositionWs = vertPositionWs.Data; - } - if (vertNormalWs != null) - { - newMesh.NormalWs = vertNormalWs.Data; - } - if (vertTangents != null) - { - newMesh.Tangents = vertTangents.Data; - } - if (vertTangents2 != null) - { - newMesh.Tangents2 = vertTangents2.Data; - } - if (vertColors != null) - { - newMesh.Colors = vertColors.Data; - } - if (vertBoneWeights != null) - { - newMesh.BoneWeights = vertBoneWeights.Data; - } - - if (vertUV2D != null) - { - for (int i = 0; i < Mesh.UVChannelCount; i++) - { - if (vertUV2D[i] != null) - { - var uvSet = vertUV2D[i].Data; - newMesh.SetUVs(i, uvSet); - } - } - } - - if (vertUV3D != null) - { - for (int i = 0; i < Mesh.UVChannelCount; i++) - { - if (vertUV3D[i] != null) - { - var uvSet = vertUV3D[i].Data; - newMesh.SetUVs(i, uvSet); - } - } - } - - if (vertUV4D != null) - { - for (int i = 0; i < Mesh.UVChannelCount; i++) - { - if (vertUV4D[i] != null) - { - var uvSet = vertUV4D[i].Data; - newMesh.SetUVs(i, uvSet); - } - } - } - - return newMesh; - } - #endregion - #endregion - } -} diff --git a/LightlessSync/ThirdParty/MeshDecimator/BoneWeight.cs b/LightlessSync/ThirdParty/MeshDecimator/BoneWeight.cs deleted file mode 100644 index 6501468..0000000 --- a/LightlessSync/ThirdParty/MeshDecimator/BoneWeight.cs +++ /dev/null @@ -1,249 +0,0 @@ -#region License -/* -MIT License - -Copyright(c) 2017-2018 Mattias Edlund - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ -#endregion - -using System; -using MeshDecimator.Math; - -namespace MeshDecimator -{ - /// - /// A bone weight. - /// - public struct BoneWeight : IEquatable - { - #region Fields - /// - /// The first bone index. - /// - public int boneIndex0; - /// - /// The second bone index. - /// - public int boneIndex1; - /// - /// The third bone index. - /// - public int boneIndex2; - /// - /// The fourth bone index. - /// - public int boneIndex3; - - /// - /// The first bone weight. - /// - public float boneWeight0; - /// - /// The second bone weight. - /// - public float boneWeight1; - /// - /// The third bone weight. - /// - public float boneWeight2; - /// - /// The fourth bone weight. - /// - public float boneWeight3; - #endregion - - #region Constructor - /// - /// Creates a new bone weight. - /// - /// The first bone index. - /// The second bone index. - /// The third bone index. - /// The fourth bone index. - /// The first bone weight. - /// The second bone weight. - /// The third bone weight. - /// The fourth bone weight. - public BoneWeight(int boneIndex0, int boneIndex1, int boneIndex2, int boneIndex3, float boneWeight0, float boneWeight1, float boneWeight2, float boneWeight3) - { - this.boneIndex0 = boneIndex0; - this.boneIndex1 = boneIndex1; - this.boneIndex2 = boneIndex2; - this.boneIndex3 = boneIndex3; - - this.boneWeight0 = boneWeight0; - this.boneWeight1 = boneWeight1; - this.boneWeight2 = boneWeight2; - this.boneWeight3 = boneWeight3; - } - #endregion - - #region Operators - /// - /// Returns if two bone weights equals eachother. - /// - /// The left hand side bone weight. - /// The right hand side bone weight. - /// If equals. - public static bool operator ==(BoneWeight lhs, BoneWeight rhs) - { - return (lhs.boneIndex0 == rhs.boneIndex0 && lhs.boneIndex1 == rhs.boneIndex1 && lhs.boneIndex2 == rhs.boneIndex2 && lhs.boneIndex3 == rhs.boneIndex3 && - new Vector4(lhs.boneWeight0, lhs.boneWeight1, lhs.boneWeight2, lhs.boneWeight3) == new Vector4(rhs.boneWeight0, rhs.boneWeight1, rhs.boneWeight2, rhs.boneWeight3)); - } - - /// - /// Returns if two bone weights don't equal eachother. - /// - /// The left hand side bone weight. - /// The right hand side bone weight. - /// If not equals. - public static bool operator !=(BoneWeight lhs, BoneWeight rhs) - { - return !(lhs == rhs); - } - #endregion - - #region Private Methods - private void MergeBoneWeight(int boneIndex, float weight) - { - if (boneIndex == boneIndex0) - { - boneWeight0 = (boneWeight0 + weight) * 0.5f; - } - else if (boneIndex == boneIndex1) - { - boneWeight1 = (boneWeight1 + weight) * 0.5f; - } - else if (boneIndex == boneIndex2) - { - boneWeight2 = (boneWeight2 + weight) * 0.5f; - } - else if (boneIndex == boneIndex3) - { - boneWeight3 = (boneWeight3 + weight) * 0.5f; - } - else if(boneWeight0 == 0f) - { - boneIndex0 = boneIndex; - boneWeight0 = weight; - } - else if (boneWeight1 == 0f) - { - boneIndex1 = boneIndex; - boneWeight1 = weight; - } - else if (boneWeight2 == 0f) - { - boneIndex2 = boneIndex; - boneWeight2 = weight; - } - else if (boneWeight3 == 0f) - { - boneIndex3 = boneIndex; - boneWeight3 = weight; - } - Normalize(); - } - - private void Normalize() - { - float mag = (float)System.Math.Sqrt(boneWeight0 * boneWeight0 + boneWeight1 * boneWeight1 + boneWeight2 * boneWeight2 + boneWeight3 * boneWeight3); - if (mag > float.Epsilon) - { - boneWeight0 /= mag; - boneWeight1 /= mag; - boneWeight2 /= mag; - boneWeight3 /= mag; - } - else - { - boneWeight0 = boneWeight1 = boneWeight2 = boneWeight3 = 0f; - } - } - #endregion - - #region Public Methods - #region Object - /// - /// Returns a hash code for this vector. - /// - /// The hash code. - public override int GetHashCode() - { - return boneIndex0.GetHashCode() ^ boneIndex1.GetHashCode() << 2 ^ boneIndex2.GetHashCode() >> 2 ^ boneIndex3.GetHashCode() >> - 1 ^ boneWeight0.GetHashCode() << 5 ^ boneWeight1.GetHashCode() << 4 ^ boneWeight2.GetHashCode() >> 4 ^ boneWeight3.GetHashCode() >> 3; - } - - /// - /// Returns if this bone weight is equal to another object. - /// - /// The other object to compare to. - /// If equals. - public override bool Equals(object obj) - { - if (!(obj is BoneWeight)) - { - return false; - } - BoneWeight other = (BoneWeight)obj; - return (boneIndex0 == other.boneIndex0 && boneIndex1 == other.boneIndex1 && boneIndex2 == other.boneIndex2 && boneIndex3 == other.boneIndex3 && - boneWeight0 == other.boneWeight0 && boneWeight1 == other.boneWeight1 && boneWeight2 == other.boneWeight2 && boneWeight3 == other.boneWeight3); - } - - /// - /// Returns if this bone weight is equal to another one. - /// - /// The other bone weight to compare to. - /// If equals. - public bool Equals(BoneWeight other) - { - return (boneIndex0 == other.boneIndex0 && boneIndex1 == other.boneIndex1 && boneIndex2 == other.boneIndex2 && boneIndex3 == other.boneIndex3 && - boneWeight0 == other.boneWeight0 && boneWeight1 == other.boneWeight1 && boneWeight2 == other.boneWeight2 && boneWeight3 == other.boneWeight3); - } - - /// - /// Returns a nicely formatted string for this bone weight. - /// - /// The string. - public override string ToString() - { - return string.Format("({0}:{4:F1}, {1}:{5:F1}, {2}:{6:F1}, {3}:{7:F1})", - boneIndex0, boneIndex1, boneIndex2, boneIndex3, boneWeight0, boneWeight1, boneWeight2, boneWeight3); - } - #endregion - - #region Static - /// - /// Merges two bone weights and stores the merged result in the first parameter. - /// - /// The first bone weight, also stores result. - /// The second bone weight. - public static void Merge(ref BoneWeight a, ref BoneWeight b) - { - if (b.boneWeight0 > 0f) a.MergeBoneWeight(b.boneIndex0, b.boneWeight0); - if (b.boneWeight1 > 0f) a.MergeBoneWeight(b.boneIndex1, b.boneWeight1); - if (b.boneWeight2 > 0f) a.MergeBoneWeight(b.boneIndex2, b.boneWeight2); - if (b.boneWeight3 > 0f) a.MergeBoneWeight(b.boneIndex3, b.boneWeight3); - } - #endregion - #endregion - } -} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Collections/ResizableArray.cs b/LightlessSync/ThirdParty/MeshDecimator/Collections/ResizableArray.cs deleted file mode 100644 index 2c69814..0000000 --- a/LightlessSync/ThirdParty/MeshDecimator/Collections/ResizableArray.cs +++ /dev/null @@ -1,179 +0,0 @@ -#region License -/* -MIT License - -Copyright(c) 2017-2018 Mattias Edlund - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ -#endregion - -using System; - -namespace MeshDecimator.Collections -{ - /// - /// A resizable array. - /// - /// The item type. - internal sealed class ResizableArray - { - #region Fields - private T[] items = null; - private int length = 0; - - private static T[] emptyArr = new T[0]; - #endregion - - #region Properties - /// - /// Gets the length of this array. - /// - public int Length - { - get { return length; } - } - - /// - /// Gets the internal data buffer for this array. - /// - public T[] Data - { - get { return items; } - } - - /// - /// Gets or sets the element value at a specific index. - /// - /// The element index. - /// The element value. - public T this[int index] - { - get { return items[index]; } - set { items[index] = value; } - } - #endregion - - #region Constructor - /// - /// Creates a new resizable array. - /// - /// The initial array capacity. - public ResizableArray(int capacity) - : this(capacity, 0) - { - - } - - /// - /// Creates a new resizable array. - /// - /// The initial array capacity. - /// The initial length of the array. - public ResizableArray(int capacity, int length) - { - if (capacity < 0) - throw new ArgumentOutOfRangeException("capacity"); - else if (length < 0 || length > capacity) - throw new ArgumentOutOfRangeException("length"); - - if (capacity > 0) - items = new T[capacity]; - else - items = emptyArr; - - this.length = length; - } - #endregion - - #region Private Methods - private void IncreaseCapacity(int capacity) - { - T[] newItems = new T[capacity]; - Array.Copy(items, 0, newItems, 0, System.Math.Min(length, capacity)); - items = newItems; - } - #endregion - - #region Public Methods - /// - /// Clears this array. - /// - public void Clear() - { - Array.Clear(items, 0, length); - length = 0; - } - - /// - /// Resizes this array. - /// - /// The new length. - /// If exess memory should be trimmed. - public void Resize(int length, bool trimExess = false) - { - if (length < 0) - throw new ArgumentOutOfRangeException("capacity"); - - if (length > items.Length) - { - IncreaseCapacity(length); - } - else if (length < this.length) - { - //Array.Clear(items, capacity, length - capacity); - } - - this.length = length; - - if (trimExess) - { - TrimExcess(); - } - } - - /// - /// Trims any excess memory for this array. - /// - public void TrimExcess() - { - if (items.Length == length) // Nothing to do - return; - - T[] newItems = new T[length]; - Array.Copy(items, 0, newItems, 0, length); - items = newItems; - } - - /// - /// Adds a new item to the end of this array. - /// - /// The new item. - public void Add(T item) - { - if (length >= items.Length) - { - IncreaseCapacity(items.Length << 1); - } - - items[length++] = item; - } - #endregion - } -} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Collections/UVChannels.cs b/LightlessSync/ThirdParty/MeshDecimator/Collections/UVChannels.cs deleted file mode 100644 index 073728a..0000000 --- a/LightlessSync/ThirdParty/MeshDecimator/Collections/UVChannels.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System; - -namespace MeshDecimator.Collections -{ - /// - /// A collection of UV channels. - /// - /// The UV vector type. - internal sealed class UVChannels - { - #region Fields - private ResizableArray[] channels = null; - private TVec[][] channelsData = null; - #endregion - - #region Properties - /// - /// Gets the channel collection data. - /// - public TVec[][] Data - { - get - { - for (int i = 0; i < Mesh.UVChannelCount; i++) - { - if (channels[i] != null) - { - channelsData[i] = channels[i].Data; - } - else - { - channelsData[i] = null; - } - } - return channelsData; - } - } - - /// - /// Gets or sets a specific channel by index. - /// - /// The channel index. - public ResizableArray this[int index] - { - get { return channels[index]; } - set { channels[index] = value; } - } - #endregion - - #region Constructor - /// - /// Creates a new collection of UV channels. - /// - public UVChannels() - { - channels = new ResizableArray[Mesh.UVChannelCount]; - channelsData = new TVec[Mesh.UVChannelCount][]; - } - #endregion - - #region Public Methods - /// - /// Resizes all channels at once. - /// - /// The new capacity. - /// If exess memory should be trimmed. - public void Resize(int capacity, bool trimExess = false) - { - for (int i = 0; i < Mesh.UVChannelCount; i++) - { - if (channels[i] != null) - { - channels[i].Resize(capacity, trimExess); - } - } - } - #endregion - } -} diff --git a/LightlessSync/ThirdParty/MeshDecimator/LICENSE.md b/LightlessSync/ThirdParty/MeshDecimator/LICENSE.md deleted file mode 100644 index 1f1f192..0000000 --- a/LightlessSync/ThirdParty/MeshDecimator/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2017-2018 Mattias Edlund - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/MathHelper.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/MathHelper.cs deleted file mode 100644 index b530d3d..0000000 --- a/LightlessSync/ThirdParty/MeshDecimator/Math/MathHelper.cs +++ /dev/null @@ -1,286 +0,0 @@ -#region License -/* -MIT License - -Copyright(c) 2017-2018 Mattias Edlund - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ -#endregion - -using System; - -namespace MeshDecimator.Math -{ - /// - /// Math helpers. - /// - public static class MathHelper - { - #region Consts - /// - /// The Pi constant. - /// - public const float PI = 3.14159274f; - - /// - /// The Pi constant. - /// - public const double PId = 3.1415926535897932384626433832795; - - /// - /// Degrees to radian constant. - /// - public const float Deg2Rad = PI / 180f; - - /// - /// Degrees to radian constant. - /// - public const double Deg2Radd = PId / 180.0; - - /// - /// Radians to degrees constant. - /// - public const float Rad2Deg = 180f / PI; - - /// - /// Radians to degrees constant. - /// - public const double Rad2Degd = 180.0 / PId; - #endregion - - #region Min - /// - /// Returns the minimum of two values. - /// - /// The first value. - /// The second value. - /// The minimum value. - public static int Min(int val1, int val2) - { - return (val1 < val2 ? val1 : val2); - } - - /// - /// Returns the minimum of three values. - /// - /// The first value. - /// The second value. - /// The third value. - /// The minimum value. - public static int Min(int val1, int val2, int val3) - { - return (val1 < val2 ? (val1 < val3 ? val1 : val3) : (val2 < val3 ? val2 : val3)); - } - - /// - /// Returns the minimum of two values. - /// - /// The first value. - /// The second value. - /// The minimum value. - public static float Min(float val1, float val2) - { - return (val1 < val2 ? val1 : val2); - } - - /// - /// Returns the minimum of three values. - /// - /// The first value. - /// The second value. - /// The third value. - /// The minimum value. - public static float Min(float val1, float val2, float val3) - { - return (val1 < val2 ? (val1 < val3 ? val1 : val3) : (val2 < val3 ? val2 : val3)); - } - - /// - /// Returns the minimum of two values. - /// - /// The first value. - /// The second value. - /// The minimum value. - public static double Min(double val1, double val2) - { - return (val1 < val2 ? val1 : val2); - } - - /// - /// Returns the minimum of three values. - /// - /// The first value. - /// The second value. - /// The third value. - /// The minimum value. - public static double Min(double val1, double val2, double val3) - { - return (val1 < val2 ? (val1 < val3 ? val1 : val3) : (val2 < val3 ? val2 : val3)); - } - #endregion - - #region Max - /// - /// Returns the maximum of two values. - /// - /// The first value. - /// The second value. - /// The maximum value. - public static int Max(int val1, int val2) - { - return (val1 > val2 ? val1 : val2); - } - - /// - /// Returns the maximum of three values. - /// - /// The first value. - /// The second value. - /// The third value. - /// The maximum value. - public static int Max(int val1, int val2, int val3) - { - return (val1 > val2 ? (val1 > val3 ? val1 : val3) : (val2 > val3 ? val2 : val3)); - } - - /// - /// Returns the maximum of two values. - /// - /// The first value. - /// The second value. - /// The maximum value. - public static float Max(float val1, float val2) - { - return (val1 > val2 ? val1 : val2); - } - - /// - /// Returns the maximum of three values. - /// - /// The first value. - /// The second value. - /// The third value. - /// The maximum value. - public static float Max(float val1, float val2, float val3) - { - return (val1 > val2 ? (val1 > val3 ? val1 : val3) : (val2 > val3 ? val2 : val3)); - } - - /// - /// Returns the maximum of two values. - /// - /// The first value. - /// The second value. - /// The maximum value. - public static double Max(double val1, double val2) - { - return (val1 > val2 ? val1 : val2); - } - - /// - /// Returns the maximum of three values. - /// - /// The first value. - /// The second value. - /// The third value. - /// The maximum value. - public static double Max(double val1, double val2, double val3) - { - return (val1 > val2 ? (val1 > val3 ? val1 : val3) : (val2 > val3 ? val2 : val3)); - } - #endregion - - #region Clamping - /// - /// Clamps a value between a minimum and a maximum value. - /// - /// The value to clamp. - /// The minimum value. - /// The maximum value. - /// The clamped value. - public static float Clamp(float value, float min, float max) - { - return (value >= min ? (value <= max ? value : max) : min); - } - - /// - /// Clamps a value between a minimum and a maximum value. - /// - /// The value to clamp. - /// The minimum value. - /// The maximum value. - /// The clamped value. - public static double Clamp(double value, double min, double max) - { - return (value >= min ? (value <= max ? value : max) : min); - } - - /// - /// Clamps the value between 0 and 1. - /// - /// The value to clamp. - /// The clamped value. - public static float Clamp01(float value) - { - return (value > 0f ? (value < 1f ? value : 1f) : 0f); - } - - /// - /// Clamps the value between 0 and 1. - /// - /// The value to clamp. - /// The clamped value. - public static double Clamp01(double value) - { - return (value > 0.0 ? (value < 1.0 ? value : 1.0) : 0.0); - } - #endregion - - #region Triangle Area - /// - /// Calculates the area of a triangle. - /// - /// The first point. - /// The second point. - /// The third point. - /// The triangle area. - public static float TriangleArea(ref Vector3 p0, ref Vector3 p1, ref Vector3 p2) - { - var dx = p1 - p0; - var dy = p2 - p0; - return dx.Magnitude * ((float)System.Math.Sin(Vector3.Angle(ref dx, ref dy) * Deg2Rad) * dy.Magnitude) * 0.5f; - } - - /// - /// Calculates the area of a triangle. - /// - /// The first point. - /// The second point. - /// The third point. - /// The triangle area. - public static double TriangleArea(ref Vector3d p0, ref Vector3d p1, ref Vector3d p2) - { - var dx = p1 - p0; - var dy = p2 - p0; - return dx.Magnitude * (System.Math.Sin(Vector3d.Angle(ref dx, ref dy) * Deg2Radd) * dy.Magnitude) * 0.5f; - } - #endregion - } -} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/SymmetricMatrix.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/SymmetricMatrix.cs deleted file mode 100644 index 3daa4e7..0000000 --- a/LightlessSync/ThirdParty/MeshDecimator/Math/SymmetricMatrix.cs +++ /dev/null @@ -1,303 +0,0 @@ -#region License -/* -MIT License - -Copyright(c) 2017-2018 Mattias Edlund - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ -#endregion - -using System; - -namespace MeshDecimator.Math -{ - /// - /// A symmetric matrix. - /// - public struct SymmetricMatrix - { - #region Fields - /// - /// The m11 component. - /// - public double m0; - /// - /// The m12 component. - /// - public double m1; - /// - /// The m13 component. - /// - public double m2; - /// - /// The m14 component. - /// - public double m3; - /// - /// The m22 component. - /// - public double m4; - /// - /// The m23 component. - /// - public double m5; - /// - /// The m24 component. - /// - public double m6; - /// - /// The m33 component. - /// - public double m7; - /// - /// The m34 component. - /// - public double m8; - /// - /// The m44 component. - /// - public double m9; - #endregion - - #region Properties - /// - /// Gets the component value with a specific index. - /// - /// The component index. - /// The value. - public double this[int index] - { - get - { - switch (index) - { - case 0: - return m0; - case 1: - return m1; - case 2: - return m2; - case 3: - return m3; - case 4: - return m4; - case 5: - return m5; - case 6: - return m6; - case 7: - return m7; - case 8: - return m8; - case 9: - return m9; - default: - throw new IndexOutOfRangeException(); - } - } - } - #endregion - - #region Constructor - /// - /// Creates a symmetric matrix with a value in each component. - /// - /// The component value. - public SymmetricMatrix(double c) - { - this.m0 = c; - this.m1 = c; - this.m2 = c; - this.m3 = c; - this.m4 = c; - this.m5 = c; - this.m6 = c; - this.m7 = c; - this.m8 = c; - this.m9 = c; - } - - /// - /// Creates a symmetric matrix. - /// - /// The m11 component. - /// The m12 component. - /// The m13 component. - /// The m14 component. - /// The m22 component. - /// The m23 component. - /// The m24 component. - /// The m33 component. - /// The m34 component. - /// The m44 component. - public SymmetricMatrix(double m0, double m1, double m2, double m3, - double m4, double m5, double m6, double m7, double m8, double m9) - { - this.m0 = m0; - this.m1 = m1; - this.m2 = m2; - this.m3 = m3; - this.m4 = m4; - this.m5 = m5; - this.m6 = m6; - this.m7 = m7; - this.m8 = m8; - this.m9 = m9; - } - - /// - /// Creates a symmetric matrix from a plane. - /// - /// The plane x-component. - /// The plane y-component - /// The plane z-component - /// The plane w-component - public SymmetricMatrix(double a, double b, double c, double d) - { - this.m0 = a * a; - this.m1 = a * b; - this.m2 = a * c; - this.m3 = a * d; - - this.m4 = b * b; - this.m5 = b * c; - this.m6 = b * d; - - this.m7 = c * c; - this.m8 = c * d; - - this.m9 = d * d; - } - #endregion - - #region Operators - /// - /// Adds two matrixes together. - /// - /// The left hand side. - /// The right hand side. - /// The resulting matrix. - public static SymmetricMatrix operator +(SymmetricMatrix a, SymmetricMatrix b) - { - return new SymmetricMatrix( - a.m0 + b.m0, a.m1 + b.m1, a.m2 + b.m2, a.m3 + b.m3, - a.m4 + b.m4, a.m5 + b.m5, a.m6 + b.m6, - a.m7 + b.m7, a.m8 + b.m8, - a.m9 + b.m9 - ); - } - #endregion - - #region Internal Methods - /// - /// Determinant(0, 1, 2, 1, 4, 5, 2, 5, 7) - /// - /// - internal double Determinant1() - { - double det = - m0 * m4 * m7 + - m2 * m1 * m5 + - m1 * m5 * m2 - - m2 * m4 * m2 - - m0 * m5 * m5 - - m1 * m1 * m7; - return det; - } - - /// - /// Determinant(1, 2, 3, 4, 5, 6, 5, 7, 8) - /// - /// - internal double Determinant2() - { - double det = - m1 * m5 * m8 + - m3 * m4 * m7 + - m2 * m6 * m5 - - m3 * m5 * m5 - - m1 * m6 * m7 - - m2 * m4 * m8; - return det; - } - - /// - /// Determinant(0, 2, 3, 1, 5, 6, 2, 7, 8) - /// - /// - internal double Determinant3() - { - double det = - m0 * m5 * m8 + - m3 * m1 * m7 + - m2 * m6 * m2 - - m3 * m5 * m2 - - m0 * m6 * m7 - - m2 * m1 * m8; - return det; - } - - /// - /// Determinant(0, 1, 3, 1, 4, 6, 2, 5, 8) - /// - /// - internal double Determinant4() - { - double det = - m0 * m4 * m8 + - m3 * m1 * m5 + - m1 * m6 * m2 - - m3 * m4 * m2 - - m0 * m6 * m5 - - m1 * m1 * m8; - return det; - } - #endregion - - #region Public Methods - /// - /// Computes the determinant of this matrix. - /// - /// The a11 index. - /// The a12 index. - /// The a13 index. - /// The a21 index. - /// The a22 index. - /// The a23 index. - /// The a31 index. - /// The a32 index. - /// The a33 index. - /// The determinant value. - public double Determinant(int a11, int a12, int a13, - int a21, int a22, int a23, - int a31, int a32, int a33) - { - double det = - this[a11] * this[a22] * this[a33] + - this[a13] * this[a21] * this[a32] + - this[a12] * this[a23] * this[a31] - - this[a13] * this[a22] * this[a31] - - this[a11] * this[a23] * this[a32] - - this[a12] * this[a21] * this[a33]; - return det; - } - #endregion - } -} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector2.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector2.cs deleted file mode 100644 index 68f06f4..0000000 --- a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector2.cs +++ /dev/null @@ -1,425 +0,0 @@ -#region License -/* -MIT License - -Copyright(c) 2017-2018 Mattias Edlund - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ -#endregion - -using System; -using System.Globalization; - -namespace MeshDecimator.Math -{ - /// - /// A single precision 2D vector. - /// - public struct Vector2 : IEquatable - { - #region Static Read-Only - /// - /// The zero vector. - /// - public static readonly Vector2 zero = new Vector2(0, 0); - #endregion - - #region Consts - /// - /// The vector epsilon. - /// - public const float Epsilon = 9.99999944E-11f; - #endregion - - #region Fields - /// - /// The x component. - /// - public float x; - /// - /// The y component. - /// - public float y; - #endregion - - #region Properties - /// - /// Gets the magnitude of this vector. - /// - public float Magnitude - { - get { return (float)System.Math.Sqrt(x * x + y * y); } - } - - /// - /// Gets the squared magnitude of this vector. - /// - public float MagnitudeSqr - { - get { return (x * x + y * y); } - } - - /// - /// Gets a normalized vector from this vector. - /// - public Vector2 Normalized - { - get - { - Vector2 result; - Normalize(ref this, out result); - return result; - } - } - - /// - /// Gets or sets a specific component by index in this vector. - /// - /// The component index. - public float this[int index] - { - get - { - switch (index) - { - case 0: - return x; - case 1: - return y; - default: - throw new IndexOutOfRangeException("Invalid Vector2 index!"); - } - } - set - { - switch (index) - { - case 0: - x = value; - break; - case 1: - y = value; - break; - default: - throw new IndexOutOfRangeException("Invalid Vector2 index!"); - } - } - } - #endregion - - #region Constructor - /// - /// Creates a new vector with one value for all components. - /// - /// The value. - public Vector2(float value) - { - this.x = value; - this.y = value; - } - - /// - /// Creates a new vector. - /// - /// The x value. - /// The y value. - public Vector2(float x, float y) - { - this.x = x; - this.y = y; - } - #endregion - - #region Operators - /// - /// Adds two vectors. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static Vector2 operator +(Vector2 a, Vector2 b) - { - return new Vector2(a.x + b.x, a.y + b.y); - } - - /// - /// Subtracts two vectors. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static Vector2 operator -(Vector2 a, Vector2 b) - { - return new Vector2(a.x - b.x, a.y - b.y); - } - - /// - /// Scales the vector uniformly. - /// - /// The vector. - /// The scaling value. - /// The resulting vector. - public static Vector2 operator *(Vector2 a, float d) - { - return new Vector2(a.x * d, a.y * d); - } - - /// - /// Scales the vector uniformly. - /// - /// The scaling value. - /// The vector. - /// The resulting vector. - public static Vector2 operator *(float d, Vector2 a) - { - return new Vector2(a.x * d, a.y * d); - } - - /// - /// Divides the vector with a float. - /// - /// The vector. - /// The dividing float value. - /// The resulting vector. - public static Vector2 operator /(Vector2 a, float d) - { - return new Vector2(a.x / d, a.y / d); - } - - /// - /// Subtracts the vector from a zero vector. - /// - /// The vector. - /// The resulting vector. - public static Vector2 operator -(Vector2 a) - { - return new Vector2(-a.x, -a.y); - } - - /// - /// Returns if two vectors equals eachother. - /// - /// The left hand side vector. - /// The right hand side vector. - /// If equals. - public static bool operator ==(Vector2 lhs, Vector2 rhs) - { - return (lhs - rhs).MagnitudeSqr < Epsilon; - } - - /// - /// Returns if two vectors don't equal eachother. - /// - /// The left hand side vector. - /// The right hand side vector. - /// If not equals. - public static bool operator !=(Vector2 lhs, Vector2 rhs) - { - return (lhs - rhs).MagnitudeSqr >= Epsilon; - } - - /// - /// Explicitly converts from a double-precision vector into a single-precision vector. - /// - /// The double-precision vector. - public static explicit operator Vector2(Vector2d v) - { - return new Vector2((float)v.x, (float)v.y); - } - - /// - /// Implicitly converts from an integer vector into a single-precision vector. - /// - /// The integer vector. - public static implicit operator Vector2(Vector2i v) - { - return new Vector2(v.x, v.y); - } - #endregion - - #region Public Methods - #region Instance - /// - /// Set x and y components of an existing vector. - /// - /// The x value. - /// The y value. - public void Set(float x, float y) - { - this.x = x; - this.y = y; - } - - /// - /// Multiplies with another vector component-wise. - /// - /// The vector to multiply with. - public void Scale(ref Vector2 scale) - { - x *= scale.x; - y *= scale.y; - } - - /// - /// Normalizes this vector. - /// - public void Normalize() - { - float mag = this.Magnitude; - if (mag > Epsilon) - { - x /= mag; - y /= mag; - } - else - { - x = y = 0; - } - } - - /// - /// Clamps this vector between a specific range. - /// - /// The minimum component value. - /// The maximum component value. - public void Clamp(float min, float max) - { - if (x < min) x = min; - else if (x > max) x = max; - - if (y < min) y = min; - else if (y > max) y = max; - } - #endregion - - #region Object - /// - /// Returns a hash code for this vector. - /// - /// The hash code. - public override int GetHashCode() - { - return x.GetHashCode() ^ y.GetHashCode() << 2; - } - - /// - /// Returns if this vector is equal to another one. - /// - /// The other vector to compare to. - /// If equals. - public override bool Equals(object other) - { - if (!(other is Vector2)) - { - return false; - } - Vector2 vector = (Vector2)other; - return (x == vector.x && y == vector.y); - } - - /// - /// Returns if this vector is equal to another one. - /// - /// The other vector to compare to. - /// If equals. - public bool Equals(Vector2 other) - { - return (x == other.x && y == other.y); - } - - /// - /// Returns a nicely formatted string for this vector. - /// - /// The string. - public override string ToString() - { - return string.Format("({0}, {1})", - x.ToString("F1", CultureInfo.InvariantCulture), - y.ToString("F1", CultureInfo.InvariantCulture)); - } - - /// - /// Returns a nicely formatted string for this vector. - /// - /// The float format. - /// The string. - public string ToString(string format) - { - return string.Format("({0}, {1})", - x.ToString(format, CultureInfo.InvariantCulture), - y.ToString(format, CultureInfo.InvariantCulture)); - } - #endregion - - #region Static - /// - /// Dot Product of two vectors. - /// - /// The left hand side vector. - /// The right hand side vector. - public static float Dot(ref Vector2 lhs, ref Vector2 rhs) - { - return lhs.x * rhs.x + lhs.y * rhs.y; - } - - /// - /// Performs a linear interpolation between two vectors. - /// - /// The vector to interpolate from. - /// The vector to interpolate to. - /// The time fraction. - /// The resulting vector. - public static void Lerp(ref Vector2 a, ref Vector2 b, float t, out Vector2 result) - { - result = new Vector2(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t); - } - - /// - /// Multiplies two vectors component-wise. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static void Scale(ref Vector2 a, ref Vector2 b, out Vector2 result) - { - result = new Vector2(a.x * b.x, a.y * b.y); - } - - /// - /// Normalizes a vector. - /// - /// The vector to normalize. - /// The resulting normalized vector. - public static void Normalize(ref Vector2 value, out Vector2 result) - { - float mag = value.Magnitude; - if (mag > Epsilon) - { - result = new Vector2(value.x / mag, value.y / mag); - } - else - { - result = Vector2.zero; - } - } - #endregion - #endregion - } -} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector2d.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector2d.cs deleted file mode 100644 index 72f62aa..0000000 --- a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector2d.cs +++ /dev/null @@ -1,425 +0,0 @@ -#region License -/* -MIT License - -Copyright(c) 2017-2018 Mattias Edlund - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ -#endregion - -using System; -using System.Globalization; - -namespace MeshDecimator.Math -{ - /// - /// A double precision 2D vector. - /// - public struct Vector2d : IEquatable - { - #region Static Read-Only - /// - /// The zero vector. - /// - public static readonly Vector2d zero = new Vector2d(0, 0); - #endregion - - #region Consts - /// - /// The vector epsilon. - /// - public const double Epsilon = double.Epsilon; - #endregion - - #region Fields - /// - /// The x component. - /// - public double x; - /// - /// The y component. - /// - public double y; - #endregion - - #region Properties - /// - /// Gets the magnitude of this vector. - /// - public double Magnitude - { - get { return System.Math.Sqrt(x * x + y * y); } - } - - /// - /// Gets the squared magnitude of this vector. - /// - public double MagnitudeSqr - { - get { return (x * x + y * y); } - } - - /// - /// Gets a normalized vector from this vector. - /// - public Vector2d Normalized - { - get - { - Vector2d result; - Normalize(ref this, out result); - return result; - } - } - - /// - /// Gets or sets a specific component by index in this vector. - /// - /// The component index. - public double this[int index] - { - get - { - switch (index) - { - case 0: - return x; - case 1: - return y; - default: - throw new IndexOutOfRangeException("Invalid Vector2d index!"); - } - } - set - { - switch (index) - { - case 0: - x = value; - break; - case 1: - y = value; - break; - default: - throw new IndexOutOfRangeException("Invalid Vector2d index!"); - } - } - } - #endregion - - #region Constructor - /// - /// Creates a new vector with one value for all components. - /// - /// The value. - public Vector2d(double value) - { - this.x = value; - this.y = value; - } - - /// - /// Creates a new vector. - /// - /// The x value. - /// The y value. - public Vector2d(double x, double y) - { - this.x = x; - this.y = y; - } - #endregion - - #region Operators - /// - /// Adds two vectors. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static Vector2d operator +(Vector2d a, Vector2d b) - { - return new Vector2d(a.x + b.x, a.y + b.y); - } - - /// - /// Subtracts two vectors. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static Vector2d operator -(Vector2d a, Vector2d b) - { - return new Vector2d(a.x - b.x, a.y - b.y); - } - - /// - /// Scales the vector uniformly. - /// - /// The vector. - /// The scaling value. - /// The resulting vector. - public static Vector2d operator *(Vector2d a, double d) - { - return new Vector2d(a.x * d, a.y * d); - } - - /// - /// Scales the vector uniformly. - /// - /// The scaling value. - /// The vector. - /// The resulting vector. - public static Vector2d operator *(double d, Vector2d a) - { - return new Vector2d(a.x * d, a.y * d); - } - - /// - /// Divides the vector with a float. - /// - /// The vector. - /// The dividing float value. - /// The resulting vector. - public static Vector2d operator /(Vector2d a, double d) - { - return new Vector2d(a.x / d, a.y / d); - } - - /// - /// Subtracts the vector from a zero vector. - /// - /// The vector. - /// The resulting vector. - public static Vector2d operator -(Vector2d a) - { - return new Vector2d(-a.x, -a.y); - } - - /// - /// Returns if two vectors equals eachother. - /// - /// The left hand side vector. - /// The right hand side vector. - /// If equals. - public static bool operator ==(Vector2d lhs, Vector2d rhs) - { - return (lhs - rhs).MagnitudeSqr < Epsilon; - } - - /// - /// Returns if two vectors don't equal eachother. - /// - /// The left hand side vector. - /// The right hand side vector. - /// If not equals. - public static bool operator !=(Vector2d lhs, Vector2d rhs) - { - return (lhs - rhs).MagnitudeSqr >= Epsilon; - } - - /// - /// Implicitly converts from a single-precision vector into a double-precision vector. - /// - /// The single-precision vector. - public static implicit operator Vector2d(Vector2 v) - { - return new Vector2d(v.x, v.y); - } - - /// - /// Implicitly converts from an integer vector into a double-precision vector. - /// - /// The integer vector. - public static implicit operator Vector2d(Vector2i v) - { - return new Vector2d(v.x, v.y); - } - #endregion - - #region Public Methods - #region Instance - /// - /// Set x and y components of an existing vector. - /// - /// The x value. - /// The y value. - public void Set(double x, double y) - { - this.x = x; - this.y = y; - } - - /// - /// Multiplies with another vector component-wise. - /// - /// The vector to multiply with. - public void Scale(ref Vector2d scale) - { - x *= scale.x; - y *= scale.y; - } - - /// - /// Normalizes this vector. - /// - public void Normalize() - { - double mag = this.Magnitude; - if (mag > Epsilon) - { - x /= mag; - y /= mag; - } - else - { - x = y = 0; - } - } - - /// - /// Clamps this vector between a specific range. - /// - /// The minimum component value. - /// The maximum component value. - public void Clamp(double min, double max) - { - if (x < min) x = min; - else if (x > max) x = max; - - if (y < min) y = min; - else if (y > max) y = max; - } - #endregion - - #region Object - /// - /// Returns a hash code for this vector. - /// - /// The hash code. - public override int GetHashCode() - { - return x.GetHashCode() ^ y.GetHashCode() << 2; - } - - /// - /// Returns if this vector is equal to another one. - /// - /// The other vector to compare to. - /// If equals. - public override bool Equals(object other) - { - if (!(other is Vector2d)) - { - return false; - } - Vector2d vector = (Vector2d)other; - return (x == vector.x && y == vector.y); - } - - /// - /// Returns if this vector is equal to another one. - /// - /// The other vector to compare to. - /// If equals. - public bool Equals(Vector2d other) - { - return (x == other.x && y == other.y); - } - - /// - /// Returns a nicely formatted string for this vector. - /// - /// The string. - public override string ToString() - { - return string.Format("({0}, {1})", - x.ToString("F1", CultureInfo.InvariantCulture), - y.ToString("F1", CultureInfo.InvariantCulture)); - } - - /// - /// Returns a nicely formatted string for this vector. - /// - /// The float format. - /// The string. - public string ToString(string format) - { - return string.Format("({0}, {1})", - x.ToString(format, CultureInfo.InvariantCulture), - y.ToString(format, CultureInfo.InvariantCulture)); - } - #endregion - - #region Static - /// - /// Dot Product of two vectors. - /// - /// The left hand side vector. - /// The right hand side vector. - public static double Dot(ref Vector2d lhs, ref Vector2d rhs) - { - return lhs.x * rhs.x + lhs.y * rhs.y; - } - - /// - /// Performs a linear interpolation between two vectors. - /// - /// The vector to interpolate from. - /// The vector to interpolate to. - /// The time fraction. - /// The resulting vector. - public static void Lerp(ref Vector2d a, ref Vector2d b, double t, out Vector2d result) - { - result = new Vector2d(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t); - } - - /// - /// Multiplies two vectors component-wise. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static void Scale(ref Vector2d a, ref Vector2d b, out Vector2d result) - { - result = new Vector2d(a.x * b.x, a.y * b.y); - } - - /// - /// Normalizes a vector. - /// - /// The vector to normalize. - /// The resulting normalized vector. - public static void Normalize(ref Vector2d value, out Vector2d result) - { - double mag = value.Magnitude; - if (mag > Epsilon) - { - result = new Vector2d(value.x / mag, value.y / mag); - } - else - { - result = Vector2d.zero; - } - } - #endregion - #endregion - } -} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector2i.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector2i.cs deleted file mode 100644 index 20b808b..0000000 --- a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector2i.cs +++ /dev/null @@ -1,348 +0,0 @@ -#region License -/* -MIT License - -Copyright(c) 2017-2018 Mattias Edlund - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ -#endregion - -using System; -using System.Globalization; - -namespace MeshDecimator.Math -{ - /// - /// A 2D integer vector. - /// - public struct Vector2i : IEquatable - { - #region Static Read-Only - /// - /// The zero vector. - /// - public static readonly Vector2i zero = new Vector2i(0, 0); - #endregion - - #region Fields - /// - /// The x component. - /// - public int x; - /// - /// The y component. - /// - public int y; - #endregion - - #region Properties - /// - /// Gets the magnitude of this vector. - /// - public int Magnitude - { - get { return (int)System.Math.Sqrt(x * x + y * y); } - } - - /// - /// Gets the squared magnitude of this vector. - /// - public int MagnitudeSqr - { - get { return (x * x + y * y); } - } - - /// - /// Gets or sets a specific component by index in this vector. - /// - /// The component index. - public int this[int index] - { - get - { - switch (index) - { - case 0: - return x; - case 1: - return y; - default: - throw new IndexOutOfRangeException("Invalid Vector2i index!"); - } - } - set - { - switch (index) - { - case 0: - x = value; - break; - case 1: - y = value; - break; - default: - throw new IndexOutOfRangeException("Invalid Vector2i index!"); - } - } - } - #endregion - - #region Constructor - /// - /// Creates a new vector with one value for all components. - /// - /// The value. - public Vector2i(int value) - { - this.x = value; - this.y = value; - } - - /// - /// Creates a new vector. - /// - /// The x value. - /// The y value. - public Vector2i(int x, int y) - { - this.x = x; - this.y = y; - } - #endregion - - #region Operators - /// - /// Adds two vectors. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static Vector2i operator +(Vector2i a, Vector2i b) - { - return new Vector2i(a.x + b.x, a.y + b.y); - } - - /// - /// Subtracts two vectors. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static Vector2i operator -(Vector2i a, Vector2i b) - { - return new Vector2i(a.x - b.x, a.y - b.y); - } - - /// - /// Scales the vector uniformly. - /// - /// The vector. - /// The scaling value. - /// The resulting vector. - public static Vector2i operator *(Vector2i a, int d) - { - return new Vector2i(a.x * d, a.y * d); - } - - /// - /// Scales the vector uniformly. - /// - /// The scaling value. - /// The vector. - /// The resulting vector. - public static Vector2i operator *(int d, Vector2i a) - { - return new Vector2i(a.x * d, a.y * d); - } - - /// - /// Divides the vector with a float. - /// - /// The vector. - /// The dividing float value. - /// The resulting vector. - public static Vector2i operator /(Vector2i a, int d) - { - return new Vector2i(a.x / d, a.y / d); - } - - /// - /// Subtracts the vector from a zero vector. - /// - /// The vector. - /// The resulting vector. - public static Vector2i operator -(Vector2i a) - { - return new Vector2i(-a.x, -a.y); - } - - /// - /// Returns if two vectors equals eachother. - /// - /// The left hand side vector. - /// The right hand side vector. - /// If equals. - public static bool operator ==(Vector2i lhs, Vector2i rhs) - { - return (lhs.x == rhs.x && lhs.y == rhs.y); - } - - /// - /// Returns if two vectors don't equal eachother. - /// - /// The left hand side vector. - /// The right hand side vector. - /// If not equals. - public static bool operator !=(Vector2i lhs, Vector2i rhs) - { - return (lhs.x != rhs.x || lhs.y != rhs.y); - } - - /// - /// Explicitly converts from a single-precision vector into an integer vector. - /// - /// The single-precision vector. - public static explicit operator Vector2i(Vector2 v) - { - return new Vector2i((int)v.x, (int)v.y); - } - - /// - /// Explicitly converts from a double-precision vector into an integer vector. - /// - /// The double-precision vector. - public static explicit operator Vector2i(Vector2d v) - { - return new Vector2i((int)v.x, (int)v.y); - } - #endregion - - #region Public Methods - #region Instance - /// - /// Set x and y components of an existing vector. - /// - /// The x value. - /// The y value. - public void Set(int x, int y) - { - this.x = x; - this.y = y; - } - - /// - /// Multiplies with another vector component-wise. - /// - /// The vector to multiply with. - public void Scale(ref Vector2i scale) - { - x *= scale.x; - y *= scale.y; - } - - /// - /// Clamps this vector between a specific range. - /// - /// The minimum component value. - /// The maximum component value. - public void Clamp(int min, int max) - { - if (x < min) x = min; - else if (x > max) x = max; - - if (y < min) y = min; - else if (y > max) y = max; - } - #endregion - - #region Object - /// - /// Returns a hash code for this vector. - /// - /// The hash code. - public override int GetHashCode() - { - return x.GetHashCode() ^ y.GetHashCode() << 2; - } - - /// - /// Returns if this vector is equal to another one. - /// - /// The other vector to compare to. - /// If equals. - public override bool Equals(object other) - { - if (!(other is Vector2i)) - { - return false; - } - Vector2i vector = (Vector2i)other; - return (x == vector.x && y == vector.y); - } - - /// - /// Returns if this vector is equal to another one. - /// - /// The other vector to compare to. - /// If equals. - public bool Equals(Vector2i other) - { - return (x == other.x && y == other.y); - } - - /// - /// Returns a nicely formatted string for this vector. - /// - /// The string. - public override string ToString() - { - return string.Format("({0}, {1})", - x.ToString(CultureInfo.InvariantCulture), - y.ToString(CultureInfo.InvariantCulture)); - } - - /// - /// Returns a nicely formatted string for this vector. - /// - /// The integer format. - /// The string. - public string ToString(string format) - { - return string.Format("({0}, {1})", - x.ToString(format, CultureInfo.InvariantCulture), - y.ToString(format, CultureInfo.InvariantCulture)); - } - #endregion - - #region Static - /// - /// Multiplies two vectors component-wise. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static void Scale(ref Vector2i a, ref Vector2i b, out Vector2i result) - { - result = new Vector2i(a.x * b.x, a.y * b.y); - } - #endregion - #endregion - } -} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector3.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector3.cs deleted file mode 100644 index 4c91aa5..0000000 --- a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector3.cs +++ /dev/null @@ -1,494 +0,0 @@ -#region License -/* -MIT License - -Copyright(c) 2017-2018 Mattias Edlund - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ -#endregion - -using System; -using System.Globalization; - -namespace MeshDecimator.Math -{ - /// - /// A single precision 3D vector. - /// - public struct Vector3 : IEquatable - { - #region Static Read-Only - /// - /// The zero vector. - /// - public static readonly Vector3 zero = new Vector3(0, 0, 0); - #endregion - - #region Consts - /// - /// The vector epsilon. - /// - public const float Epsilon = 9.99999944E-11f; - #endregion - - #region Fields - /// - /// The x component. - /// - public float x; - /// - /// The y component. - /// - public float y; - /// - /// The z component. - /// - public float z; - #endregion - - #region Properties - /// - /// Gets the magnitude of this vector. - /// - public float Magnitude - { - get { return (float)System.Math.Sqrt(x * x + y * y + z * z); } - } - - /// - /// Gets the squared magnitude of this vector. - /// - public float MagnitudeSqr - { - get { return (x * x + y * y + z * z); } - } - - /// - /// Gets a normalized vector from this vector. - /// - public Vector3 Normalized - { - get - { - Vector3 result; - Normalize(ref this, out result); - return result; - } - } - - /// - /// Gets or sets a specific component by index in this vector. - /// - /// The component index. - public float this[int index] - { - get - { - switch (index) - { - case 0: - return x; - case 1: - return y; - case 2: - return z; - default: - throw new IndexOutOfRangeException("Invalid Vector3 index!"); - } - } - set - { - switch (index) - { - case 0: - x = value; - break; - case 1: - y = value; - break; - case 2: - z = value; - break; - default: - throw new IndexOutOfRangeException("Invalid Vector3 index!"); - } - } - } - #endregion - - #region Constructor - /// - /// Creates a new vector with one value for all components. - /// - /// The value. - public Vector3(float value) - { - this.x = value; - this.y = value; - this.z = value; - } - - /// - /// Creates a new vector. - /// - /// The x value. - /// The y value. - /// The z value. - public Vector3(float x, float y, float z) - { - this.x = x; - this.y = y; - this.z = z; - } - - /// - /// Creates a new vector from a double precision vector. - /// - /// The double precision vector. - public Vector3(Vector3d vector) - { - this.x = (float)vector.x; - this.y = (float)vector.y; - this.z = (float)vector.z; - } - #endregion - - #region Operators - /// - /// Adds two vectors. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static Vector3 operator +(Vector3 a, Vector3 b) - { - return new Vector3(a.x + b.x, a.y + b.y, a.z + b.z); - } - - /// - /// Subtracts two vectors. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static Vector3 operator -(Vector3 a, Vector3 b) - { - return new Vector3(a.x - b.x, a.y - b.y, a.z - b.z); - } - - /// - /// Scales the vector uniformly. - /// - /// The vector. - /// The scaling value. - /// The resulting vector. - public static Vector3 operator *(Vector3 a, float d) - { - return new Vector3(a.x * d, a.y * d, a.z * d); - } - - /// - /// Scales the vector uniformly. - /// - /// The scaling value. - /// The vector. - /// The resulting vector. - public static Vector3 operator *(float d, Vector3 a) - { - return new Vector3(a.x * d, a.y * d, a.z * d); - } - - /// - /// Divides the vector with a float. - /// - /// The vector. - /// The dividing float value. - /// The resulting vector. - public static Vector3 operator /(Vector3 a, float d) - { - return new Vector3(a.x / d, a.y / d, a.z / d); - } - - /// - /// Subtracts the vector from a zero vector. - /// - /// The vector. - /// The resulting vector. - public static Vector3 operator -(Vector3 a) - { - return new Vector3(-a.x, -a.y, -a.z); - } - - /// - /// Returns if two vectors equals eachother. - /// - /// The left hand side vector. - /// The right hand side vector. - /// If equals. - public static bool operator ==(Vector3 lhs, Vector3 rhs) - { - return (lhs - rhs).MagnitudeSqr < Epsilon; - } - - /// - /// Returns if two vectors don't equal eachother. - /// - /// The left hand side vector. - /// The right hand side vector. - /// If not equals. - public static bool operator !=(Vector3 lhs, Vector3 rhs) - { - return (lhs - rhs).MagnitudeSqr >= Epsilon; - } - - /// - /// Explicitly converts from a double-precision vector into a single-precision vector. - /// - /// The double-precision vector. - public static explicit operator Vector3(Vector3d v) - { - return new Vector3((float)v.x, (float)v.y, (float)v.z); - } - - /// - /// Implicitly converts from an integer vector into a single-precision vector. - /// - /// The integer vector. - public static implicit operator Vector3(Vector3i v) - { - return new Vector3(v.x, v.y, v.z); - } - #endregion - - #region Public Methods - #region Instance - /// - /// Set x, y and z components of an existing vector. - /// - /// The x value. - /// The y value. - /// The z value. - public void Set(float x, float y, float z) - { - this.x = x; - this.y = y; - this.z = z; - } - - /// - /// Multiplies with another vector component-wise. - /// - /// The vector to multiply with. - public void Scale(ref Vector3 scale) - { - x *= scale.x; - y *= scale.y; - z *= scale.z; - } - - /// - /// Normalizes this vector. - /// - public void Normalize() - { - float mag = this.Magnitude; - if (mag > Epsilon) - { - x /= mag; - y /= mag; - z /= mag; - } - else - { - x = y = z = 0; - } - } - - /// - /// Clamps this vector between a specific range. - /// - /// The minimum component value. - /// The maximum component value. - public void Clamp(float min, float max) - { - if (x < min) x = min; - else if (x > max) x = max; - - if (y < min) y = min; - else if (y > max) y = max; - - if (z < min) z = min; - else if (z > max) z = max; - } - #endregion - - #region Object - /// - /// Returns a hash code for this vector. - /// - /// The hash code. - public override int GetHashCode() - { - return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2; - } - - /// - /// Returns if this vector is equal to another one. - /// - /// The other vector to compare to. - /// If equals. - public override bool Equals(object other) - { - if (!(other is Vector3)) - { - return false; - } - Vector3 vector = (Vector3)other; - return (x == vector.x && y == vector.y && z == vector.z); - } - - /// - /// Returns if this vector is equal to another one. - /// - /// The other vector to compare to. - /// If equals. - public bool Equals(Vector3 other) - { - return (x == other.x && y == other.y && z == other.z); - } - - /// - /// Returns a nicely formatted string for this vector. - /// - /// The string. - public override string ToString() - { - return string.Format("({0}, {1}, {2})", - x.ToString("F1", CultureInfo.InvariantCulture), - y.ToString("F1", CultureInfo.InvariantCulture), - z.ToString("F1", CultureInfo.InvariantCulture)); - } - - /// - /// Returns a nicely formatted string for this vector. - /// - /// The float format. - /// The string. - public string ToString(string format) - { - return string.Format("({0}, {1}, {2})", - x.ToString(format, CultureInfo.InvariantCulture), - y.ToString(format, CultureInfo.InvariantCulture), - z.ToString(format, CultureInfo.InvariantCulture)); - } - #endregion - - #region Static - /// - /// Dot Product of two vectors. - /// - /// The left hand side vector. - /// The right hand side vector. - public static float Dot(ref Vector3 lhs, ref Vector3 rhs) - { - return lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z; - } - - /// - /// Cross Product of two vectors. - /// - /// The left hand side vector. - /// The right hand side vector. - /// The resulting vector. - public static void Cross(ref Vector3 lhs, ref Vector3 rhs, out Vector3 result) - { - result = new Vector3(lhs.y * rhs.z - lhs.z * rhs.y, lhs.z * rhs.x - lhs.x * rhs.z, lhs.x * rhs.y - lhs.y * rhs.x); - } - - /// - /// Calculates the angle between two vectors. - /// - /// The from vector. - /// The to vector. - /// The angle. - public static float Angle(ref Vector3 from, ref Vector3 to) - { - Vector3 fromNormalized = from.Normalized; - Vector3 toNormalized = to.Normalized; - return (float)System.Math.Acos(MathHelper.Clamp(Vector3.Dot(ref fromNormalized, ref toNormalized), -1f, 1f)) * MathHelper.Rad2Deg; - } - - /// - /// Performs a linear interpolation between two vectors. - /// - /// The vector to interpolate from. - /// The vector to interpolate to. - /// The time fraction. - /// The resulting vector. - public static void Lerp(ref Vector3 a, ref Vector3 b, float t, out Vector3 result) - { - result = new Vector3(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t); - } - - /// - /// Multiplies two vectors component-wise. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static void Scale(ref Vector3 a, ref Vector3 b, out Vector3 result) - { - result = new Vector3(a.x * b.x, a.y * b.y, a.z * b.z); - } - - /// - /// Normalizes a vector. - /// - /// The vector to normalize. - /// The resulting normalized vector. - public static void Normalize(ref Vector3 value, out Vector3 result) - { - float mag = value.Magnitude; - if (mag > Epsilon) - { - result = new Vector3(value.x / mag, value.y / mag, value.z / mag); - } - else - { - result = Vector3.zero; - } - } - - /// - /// Normalizes both vectors and makes them orthogonal to each other. - /// - /// The normal vector. - /// The tangent. - public static void OrthoNormalize(ref Vector3 normal, ref Vector3 tangent) - { - normal.Normalize(); - Vector3 proj = normal * Vector3.Dot(ref tangent, ref normal); - tangent -= proj; - tangent.Normalize(); - } - #endregion - #endregion - } -} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector3d.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector3d.cs deleted file mode 100644 index 11ebed1..0000000 --- a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector3d.cs +++ /dev/null @@ -1,481 +0,0 @@ -#region License -/* -MIT License - -Copyright(c) 2017-2018 Mattias Edlund - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ -#endregion - -using System; -using System.Globalization; - -namespace MeshDecimator.Math -{ - /// - /// A double precision 3D vector. - /// - public struct Vector3d : IEquatable - { - #region Static Read-Only - /// - /// The zero vector. - /// - public static readonly Vector3d zero = new Vector3d(0, 0, 0); - #endregion - - #region Consts - /// - /// The vector epsilon. - /// - public const double Epsilon = double.Epsilon; - #endregion - - #region Fields - /// - /// The x component. - /// - public double x; - /// - /// The y component. - /// - public double y; - /// - /// The z component. - /// - public double z; - #endregion - - #region Properties - /// - /// Gets the magnitude of this vector. - /// - public double Magnitude - { - get { return System.Math.Sqrt(x * x + y * y + z * z); } - } - - /// - /// Gets the squared magnitude of this vector. - /// - public double MagnitudeSqr - { - get { return (x * x + y * y + z * z); } - } - - /// - /// Gets a normalized vector from this vector. - /// - public Vector3d Normalized - { - get - { - Vector3d result; - Normalize(ref this, out result); - return result; - } - } - - /// - /// Gets or sets a specific component by index in this vector. - /// - /// The component index. - public double this[int index] - { - get - { - switch (index) - { - case 0: - return x; - case 1: - return y; - case 2: - return z; - default: - throw new IndexOutOfRangeException("Invalid Vector3d index!"); - } - } - set - { - switch (index) - { - case 0: - x = value; - break; - case 1: - y = value; - break; - case 2: - z = value; - break; - default: - throw new IndexOutOfRangeException("Invalid Vector3d index!"); - } - } - } - #endregion - - #region Constructor - /// - /// Creates a new vector with one value for all components. - /// - /// The value. - public Vector3d(double value) - { - this.x = value; - this.y = value; - this.z = value; - } - - /// - /// Creates a new vector. - /// - /// The x value. - /// The y value. - /// The z value. - public Vector3d(double x, double y, double z) - { - this.x = x; - this.y = y; - this.z = z; - } - - /// - /// Creates a new vector from a single precision vector. - /// - /// The single precision vector. - public Vector3d(Vector3 vector) - { - this.x = vector.x; - this.y = vector.y; - this.z = vector.z; - } - #endregion - - #region Operators - /// - /// Adds two vectors. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static Vector3d operator +(Vector3d a, Vector3d b) - { - return new Vector3d(a.x + b.x, a.y + b.y, a.z + b.z); - } - - /// - /// Subtracts two vectors. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static Vector3d operator -(Vector3d a, Vector3d b) - { - return new Vector3d(a.x - b.x, a.y - b.y, a.z - b.z); - } - - /// - /// Scales the vector uniformly. - /// - /// The vector. - /// The scaling value. - /// The resulting vector. - public static Vector3d operator *(Vector3d a, double d) - { - return new Vector3d(a.x * d, a.y * d, a.z * d); - } - - /// - /// Scales the vector uniformly. - /// - /// The scaling value. - /// The vector. - /// The resulting vector. - public static Vector3d operator *(double d, Vector3d a) - { - return new Vector3d(a.x * d, a.y * d, a.z * d); - } - - /// - /// Divides the vector with a float. - /// - /// The vector. - /// The dividing float value. - /// The resulting vector. - public static Vector3d operator /(Vector3d a, double d) - { - return new Vector3d(a.x / d, a.y / d, a.z / d); - } - - /// - /// Subtracts the vector from a zero vector. - /// - /// The vector. - /// The resulting vector. - public static Vector3d operator -(Vector3d a) - { - return new Vector3d(-a.x, -a.y, -a.z); - } - - /// - /// Returns if two vectors equals eachother. - /// - /// The left hand side vector. - /// The right hand side vector. - /// If equals. - public static bool operator ==(Vector3d lhs, Vector3d rhs) - { - return (lhs - rhs).MagnitudeSqr < Epsilon; - } - - /// - /// Returns if two vectors don't equal eachother. - /// - /// The left hand side vector. - /// The right hand side vector. - /// If not equals. - public static bool operator !=(Vector3d lhs, Vector3d rhs) - { - return (lhs - rhs).MagnitudeSqr >= Epsilon; - } - - /// - /// Implicitly converts from a single-precision vector into a double-precision vector. - /// - /// The single-precision vector. - public static implicit operator Vector3d(Vector3 v) - { - return new Vector3d(v.x, v.y, v.z); - } - - /// - /// Implicitly converts from an integer vector into a double-precision vector. - /// - /// The integer vector. - public static implicit operator Vector3d(Vector3i v) - { - return new Vector3d(v.x, v.y, v.z); - } - #endregion - - #region Public Methods - #region Instance - /// - /// Set x, y and z components of an existing vector. - /// - /// The x value. - /// The y value. - /// The z value. - public void Set(double x, double y, double z) - { - this.x = x; - this.y = y; - this.z = z; - } - - /// - /// Multiplies with another vector component-wise. - /// - /// The vector to multiply with. - public void Scale(ref Vector3d scale) - { - x *= scale.x; - y *= scale.y; - z *= scale.z; - } - - /// - /// Normalizes this vector. - /// - public void Normalize() - { - double mag = this.Magnitude; - if (mag > Epsilon) - { - x /= mag; - y /= mag; - z /= mag; - } - else - { - x = y = z = 0; - } - } - - /// - /// Clamps this vector between a specific range. - /// - /// The minimum component value. - /// The maximum component value. - public void Clamp(double min, double max) - { - if (x < min) x = min; - else if (x > max) x = max; - - if (y < min) y = min; - else if (y > max) y = max; - - if (z < min) z = min; - else if (z > max) z = max; - } - #endregion - - #region Object - /// - /// Returns a hash code for this vector. - /// - /// The hash code. - public override int GetHashCode() - { - return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2; - } - - /// - /// Returns if this vector is equal to another one. - /// - /// The other vector to compare to. - /// If equals. - public override bool Equals(object other) - { - if (!(other is Vector3d)) - { - return false; - } - Vector3d vector = (Vector3d)other; - return (x == vector.x && y == vector.y && z == vector.z); - } - - /// - /// Returns if this vector is equal to another one. - /// - /// The other vector to compare to. - /// If equals. - public bool Equals(Vector3d other) - { - return (x == other.x && y == other.y && z == other.z); - } - - /// - /// Returns a nicely formatted string for this vector. - /// - /// The string. - public override string ToString() - { - return string.Format("({0}, {1}, {2})", - x.ToString("F1", CultureInfo.InvariantCulture), - y.ToString("F1", CultureInfo.InvariantCulture), - z.ToString("F1", CultureInfo.InvariantCulture)); - } - - /// - /// Returns a nicely formatted string for this vector. - /// - /// The float format. - /// The string. - public string ToString(string format) - { - return string.Format("({0}, {1}, {2})", - x.ToString(format, CultureInfo.InvariantCulture), - y.ToString(format, CultureInfo.InvariantCulture), - z.ToString(format, CultureInfo.InvariantCulture)); - } - #endregion - - #region Static - /// - /// Dot Product of two vectors. - /// - /// The left hand side vector. - /// The right hand side vector. - public static double Dot(ref Vector3d lhs, ref Vector3d rhs) - { - return lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z; - } - - /// - /// Cross Product of two vectors. - /// - /// The left hand side vector. - /// The right hand side vector. - /// The resulting vector. - public static void Cross(ref Vector3d lhs, ref Vector3d rhs, out Vector3d result) - { - result = new Vector3d(lhs.y * rhs.z - lhs.z * rhs.y, lhs.z * rhs.x - lhs.x * rhs.z, lhs.x * rhs.y - lhs.y * rhs.x); - } - - /// - /// Calculates the angle between two vectors. - /// - /// The from vector. - /// The to vector. - /// The angle. - public static double Angle(ref Vector3d from, ref Vector3d to) - { - Vector3d fromNormalized = from.Normalized; - Vector3d toNormalized = to.Normalized; - return System.Math.Acos(MathHelper.Clamp(Vector3d.Dot(ref fromNormalized, ref toNormalized), -1.0, 1.0)) * MathHelper.Rad2Degd; - } - - /// - /// Performs a linear interpolation between two vectors. - /// - /// The vector to interpolate from. - /// The vector to interpolate to. - /// The time fraction. - /// The resulting vector. - public static void Lerp(ref Vector3d a, ref Vector3d b, double t, out Vector3d result) - { - result = new Vector3d(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t); - } - - /// - /// Multiplies two vectors component-wise. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static void Scale(ref Vector3d a, ref Vector3d b, out Vector3d result) - { - result = new Vector3d(a.x * b.x, a.y * b.y, a.z * b.z); - } - - /// - /// Normalizes a vector. - /// - /// The vector to normalize. - /// The resulting normalized vector. - public static void Normalize(ref Vector3d value, out Vector3d result) - { - double mag = value.Magnitude; - if (mag > Epsilon) - { - result = new Vector3d(value.x / mag, value.y / mag, value.z / mag); - } - else - { - result = Vector3d.zero; - } - } - #endregion - #endregion - } -} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector3i.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector3i.cs deleted file mode 100644 index d36d6d1..0000000 --- a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector3i.cs +++ /dev/null @@ -1,368 +0,0 @@ -#region License -/* -MIT License - -Copyright(c) 2017-2018 Mattias Edlund - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ -#endregion - -using System; -using System.Globalization; - -namespace MeshDecimator.Math -{ - /// - /// A 3D integer vector. - /// - public struct Vector3i : IEquatable - { - #region Static Read-Only - /// - /// The zero vector. - /// - public static readonly Vector3i zero = new Vector3i(0, 0, 0); - #endregion - - #region Fields - /// - /// The x component. - /// - public int x; - /// - /// The y component. - /// - public int y; - /// - /// The z component. - /// - public int z; - #endregion - - #region Properties - /// - /// Gets the magnitude of this vector. - /// - public int Magnitude - { - get { return (int)System.Math.Sqrt(x * x + y * y + z * z); } - } - - /// - /// Gets the squared magnitude of this vector. - /// - public int MagnitudeSqr - { - get { return (x * x + y * y + z * z); } - } - - /// - /// Gets or sets a specific component by index in this vector. - /// - /// The component index. - public int this[int index] - { - get - { - switch (index) - { - case 0: - return x; - case 1: - return y; - case 2: - return z; - default: - throw new IndexOutOfRangeException("Invalid Vector3i index!"); - } - } - set - { - switch (index) - { - case 0: - x = value; - break; - case 1: - y = value; - break; - case 2: - z = value; - break; - default: - throw new IndexOutOfRangeException("Invalid Vector3i index!"); - } - } - } - #endregion - - #region Constructor - /// - /// Creates a new vector with one value for all components. - /// - /// The value. - public Vector3i(int value) - { - this.x = value; - this.y = value; - this.z = value; - } - - /// - /// Creates a new vector. - /// - /// The x value. - /// The y value. - /// The z value. - public Vector3i(int x, int y, int z) - { - this.x = x; - this.y = y; - this.z = z; - } - #endregion - - #region Operators - /// - /// Adds two vectors. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static Vector3i operator +(Vector3i a, Vector3i b) - { - return new Vector3i(a.x + b.x, a.y + b.y, a.z + b.z); - } - - /// - /// Subtracts two vectors. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static Vector3i operator -(Vector3i a, Vector3i b) - { - return new Vector3i(a.x - b.x, a.y - b.y, a.z - b.z); - } - - /// - /// Scales the vector uniformly. - /// - /// The vector. - /// The scaling value. - /// The resulting vector. - public static Vector3i operator *(Vector3i a, int d) - { - return new Vector3i(a.x * d, a.y * d, a.z * d); - } - - /// - /// Scales the vector uniformly. - /// - /// The scaling value. - /// The vector. - /// The resulting vector. - public static Vector3i operator *(int d, Vector3i a) - { - return new Vector3i(a.x * d, a.y * d, a.z * d); - } - - /// - /// Divides the vector with a float. - /// - /// The vector. - /// The dividing float value. - /// The resulting vector. - public static Vector3i operator /(Vector3i a, int d) - { - return new Vector3i(a.x / d, a.y / d, a.z / d); - } - - /// - /// Subtracts the vector from a zero vector. - /// - /// The vector. - /// The resulting vector. - public static Vector3i operator -(Vector3i a) - { - return new Vector3i(-a.x, -a.y, -a.z); - } - - /// - /// Returns if two vectors equals eachother. - /// - /// The left hand side vector. - /// The right hand side vector. - /// If equals. - public static bool operator ==(Vector3i lhs, Vector3i rhs) - { - return (lhs.x == rhs.x && lhs.y == rhs.y && lhs.z == rhs.z); - } - - /// - /// Returns if two vectors don't equal eachother. - /// - /// The left hand side vector. - /// The right hand side vector. - /// If not equals. - public static bool operator !=(Vector3i lhs, Vector3i rhs) - { - return (lhs.x != rhs.x || lhs.y != rhs.y || lhs.z != rhs.z); - } - - /// - /// Explicitly converts from a single-precision vector into an integer vector. - /// - /// The single-precision vector. - public static implicit operator Vector3i(Vector3 v) - { - return new Vector3i((int)v.x, (int)v.y, (int)v.z); - } - - /// - /// Explicitly converts from a double-precision vector into an integer vector. - /// - /// The double-precision vector. - public static explicit operator Vector3i(Vector3d v) - { - return new Vector3i((int)v.x, (int)v.y, (int)v.z); - } - #endregion - - #region Public Methods - #region Instance - /// - /// Set x, y and z components of an existing vector. - /// - /// The x value. - /// The y value. - /// The z value. - public void Set(int x, int y, int z) - { - this.x = x; - this.y = y; - this.z = z; - } - - /// - /// Multiplies with another vector component-wise. - /// - /// The vector to multiply with. - public void Scale(ref Vector3i scale) - { - x *= scale.x; - y *= scale.y; - z *= scale.z; - } - - /// - /// Clamps this vector between a specific range. - /// - /// The minimum component value. - /// The maximum component value. - public void Clamp(int min, int max) - { - if (x < min) x = min; - else if (x > max) x = max; - - if (y < min) y = min; - else if (y > max) y = max; - - if (z < min) z = min; - else if (z > max) z = max; - } - #endregion - - #region Object - /// - /// Returns a hash code for this vector. - /// - /// The hash code. - public override int GetHashCode() - { - return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2; - } - - /// - /// Returns if this vector is equal to another one. - /// - /// The other vector to compare to. - /// If equals. - public override bool Equals(object other) - { - if (!(other is Vector3i)) - { - return false; - } - Vector3i vector = (Vector3i)other; - return (x == vector.x && y == vector.y && z == vector.z); - } - - /// - /// Returns if this vector is equal to another one. - /// - /// The other vector to compare to. - /// If equals. - public bool Equals(Vector3i other) - { - return (x == other.x && y == other.y && z == other.z); - } - - /// - /// Returns a nicely formatted string for this vector. - /// - /// The string. - public override string ToString() - { - return string.Format("({0}, {1}, {2})", - x.ToString(CultureInfo.InvariantCulture), - y.ToString(CultureInfo.InvariantCulture), - z.ToString(CultureInfo.InvariantCulture)); - } - - /// - /// Returns a nicely formatted string for this vector. - /// - /// The integer format. - /// The string. - public string ToString(string format) - { - return string.Format("({0}, {1}, {2})", - x.ToString(format, CultureInfo.InvariantCulture), - y.ToString(format, CultureInfo.InvariantCulture), - z.ToString(format, CultureInfo.InvariantCulture)); - } - #endregion - - #region Static - /// - /// Multiplies two vectors component-wise. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static void Scale(ref Vector3i a, ref Vector3i b, out Vector3i result) - { - result = new Vector3i(a.x * b.x, a.y * b.y, a.z * b.z); - } - #endregion - #endregion - } -} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector4.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector4.cs deleted file mode 100644 index bf1d655..0000000 --- a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector4.cs +++ /dev/null @@ -1,467 +0,0 @@ -#region License -/* -MIT License - -Copyright(c) 2017-2018 Mattias Edlund - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ -#endregion - -using System; -using System.Globalization; - -namespace MeshDecimator.Math -{ - /// - /// A single precision 4D vector. - /// - public struct Vector4 : IEquatable - { - #region Static Read-Only - /// - /// The zero vector. - /// - public static readonly Vector4 zero = new Vector4(0, 0, 0, 0); - #endregion - - #region Consts - /// - /// The vector epsilon. - /// - public const float Epsilon = 9.99999944E-11f; - #endregion - - #region Fields - /// - /// The x component. - /// - public float x; - /// - /// The y component. - /// - public float y; - /// - /// The z component. - /// - public float z; - /// - /// The w component. - /// - public float w; - #endregion - - #region Properties - /// - /// Gets the magnitude of this vector. - /// - public float Magnitude - { - get { return (float)System.Math.Sqrt(x * x + y * y + z * z + w * w); } - } - - /// - /// Gets the squared magnitude of this vector. - /// - public float MagnitudeSqr - { - get { return (x * x + y * y + z * z + w * w); } - } - - /// - /// Gets a normalized vector from this vector. - /// - public Vector4 Normalized - { - get - { - Vector4 result; - Normalize(ref this, out result); - return result; - } - } - - /// - /// Gets or sets a specific component by index in this vector. - /// - /// The component index. - public float this[int index] - { - get - { - switch (index) - { - case 0: - return x; - case 1: - return y; - case 2: - return z; - case 3: - return w; - default: - throw new IndexOutOfRangeException("Invalid Vector4 index!"); - } - } - set - { - switch (index) - { - case 0: - x = value; - break; - case 1: - y = value; - break; - case 2: - z = value; - break; - case 3: - w = value; - break; - default: - throw new IndexOutOfRangeException("Invalid Vector4 index!"); - } - } - } - #endregion - - #region Constructor - /// - /// Creates a new vector with one value for all components. - /// - /// The value. - public Vector4(float value) - { - this.x = value; - this.y = value; - this.z = value; - this.w = value; - } - - /// - /// Creates a new vector. - /// - /// The x value. - /// The y value. - /// The z value. - /// The w value. - public Vector4(float x, float y, float z, float w) - { - this.x = x; - this.y = y; - this.z = z; - this.w = w; - } - #endregion - - #region Operators - /// - /// Adds two vectors. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static Vector4 operator +(Vector4 a, Vector4 b) - { - return new Vector4(a.x + b.x, a.y + b.y, a.z + b.z, a.w + b.w); - } - - /// - /// Subtracts two vectors. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static Vector4 operator -(Vector4 a, Vector4 b) - { - return new Vector4(a.x - b.x, a.y - b.y, a.z - b.z, a.w - b.w); - } - - /// - /// Scales the vector uniformly. - /// - /// The vector. - /// The scaling value. - /// The resulting vector. - public static Vector4 operator *(Vector4 a, float d) - { - return new Vector4(a.x * d, a.y * d, a.z * d, a.w * d); - } - - /// - /// Scales the vector uniformly. - /// - /// The scaling value. - /// The vector. - /// The resulting vector. - public static Vector4 operator *(float d, Vector4 a) - { - return new Vector4(a.x * d, a.y * d, a.z * d, a.w * d); - } - - /// - /// Divides the vector with a float. - /// - /// The vector. - /// The dividing float value. - /// The resulting vector. - public static Vector4 operator /(Vector4 a, float d) - { - return new Vector4(a.x / d, a.y / d, a.z / d, a.w / d); - } - - /// - /// Subtracts the vector from a zero vector. - /// - /// The vector. - /// The resulting vector. - public static Vector4 operator -(Vector4 a) - { - return new Vector4(-a.x, -a.y, -a.z, -a.w); - } - - /// - /// Returns if two vectors equals eachother. - /// - /// The left hand side vector. - /// The right hand side vector. - /// If equals. - public static bool operator ==(Vector4 lhs, Vector4 rhs) - { - return (lhs - rhs).MagnitudeSqr < Epsilon; - } - - /// - /// Returns if two vectors don't equal eachother. - /// - /// The left hand side vector. - /// The right hand side vector. - /// If not equals. - public static bool operator !=(Vector4 lhs, Vector4 rhs) - { - return (lhs - rhs).MagnitudeSqr >= Epsilon; - } - - /// - /// Explicitly converts from a double-precision vector into a single-precision vector. - /// - /// The double-precision vector. - public static explicit operator Vector4(Vector4d v) - { - return new Vector4((float)v.x, (float)v.y, (float)v.z, (float)v.w); - } - - /// - /// Implicitly converts from an integer vector into a single-precision vector. - /// - /// The integer vector. - public static implicit operator Vector4(Vector4i v) - { - return new Vector4(v.x, v.y, v.z, v.w); - } - #endregion - - #region Public Methods - #region Instance - /// - /// Set x, y and z components of an existing vector. - /// - /// The x value. - /// The y value. - /// The z value. - /// The w value. - public void Set(float x, float y, float z, float w) - { - this.x = x; - this.y = y; - this.z = z; - this.w = w; - } - - /// - /// Multiplies with another vector component-wise. - /// - /// The vector to multiply with. - public void Scale(ref Vector4 scale) - { - x *= scale.x; - y *= scale.y; - z *= scale.z; - w *= scale.w; - } - - /// - /// Normalizes this vector. - /// - public void Normalize() - { - float mag = this.Magnitude; - if (mag > Epsilon) - { - x /= mag; - y /= mag; - z /= mag; - w /= mag; - } - else - { - x = y = z = w = 0; - } - } - - /// - /// Clamps this vector between a specific range. - /// - /// The minimum component value. - /// The maximum component value. - public void Clamp(float min, float max) - { - if (x < min) x = min; - else if (x > max) x = max; - - if (y < min) y = min; - else if (y > max) y = max; - - if (z < min) z = min; - else if (z > max) z = max; - - if (w < min) w = min; - else if (w > max) w = max; - } - #endregion - - #region Object - /// - /// Returns a hash code for this vector. - /// - /// The hash code. - public override int GetHashCode() - { - return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2 ^ w.GetHashCode() >> 1; - } - - /// - /// Returns if this vector is equal to another one. - /// - /// The other vector to compare to. - /// If equals. - public override bool Equals(object other) - { - if (!(other is Vector4)) - { - return false; - } - Vector4 vector = (Vector4)other; - return (x == vector.x && y == vector.y && z == vector.z && w == vector.w); - } - - /// - /// Returns if this vector is equal to another one. - /// - /// The other vector to compare to. - /// If equals. - public bool Equals(Vector4 other) - { - return (x == other.x && y == other.y && z == other.z && w == other.w); - } - - /// - /// Returns a nicely formatted string for this vector. - /// - /// The string. - public override string ToString() - { - return string.Format("({0}, {1}, {2}, {3})", - x.ToString("F1", CultureInfo.InvariantCulture), - y.ToString("F1", CultureInfo.InvariantCulture), - z.ToString("F1", CultureInfo.InvariantCulture), - w.ToString("F1", CultureInfo.InvariantCulture)); - } - - /// - /// Returns a nicely formatted string for this vector. - /// - /// The float format. - /// The string. - public string ToString(string format) - { - return string.Format("({0}, {1}, {2}, {3})", - x.ToString(format, CultureInfo.InvariantCulture), - y.ToString(format, CultureInfo.InvariantCulture), - z.ToString(format, CultureInfo.InvariantCulture), - w.ToString(format, CultureInfo.InvariantCulture)); - } - #endregion - - #region Static - /// - /// Dot Product of two vectors. - /// - /// The left hand side vector. - /// The right hand side vector. - public static float Dot(ref Vector4 lhs, ref Vector4 rhs) - { - return lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z + lhs.w * rhs.w; - } - - /// - /// Performs a linear interpolation between two vectors. - /// - /// The vector to interpolate from. - /// The vector to interpolate to. - /// The time fraction. - /// The resulting vector. - public static void Lerp(ref Vector4 a, ref Vector4 b, float t, out Vector4 result) - { - result = new Vector4(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t, a.w + (b.w - a.w) * t); - } - - /// - /// Multiplies two vectors component-wise. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static void Scale(ref Vector4 a, ref Vector4 b, out Vector4 result) - { - result = new Vector4(a.x * b.x, a.y * b.y, a.z * b.z, a.w * b.w); - } - - /// - /// Normalizes a vector. - /// - /// The vector to normalize. - /// The resulting normalized vector. - public static void Normalize(ref Vector4 value, out Vector4 result) - { - float mag = value.Magnitude; - if (mag > Epsilon) - { - result = new Vector4(value.x / mag, value.y / mag, value.z / mag, value.w / mag); - } - else - { - result = Vector4.zero; - } - } - #endregion - #endregion - } -} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector4d.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector4d.cs deleted file mode 100644 index c984c08..0000000 --- a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector4d.cs +++ /dev/null @@ -1,467 +0,0 @@ -#region License -/* -MIT License - -Copyright(c) 2017-2018 Mattias Edlund - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ -#endregion - -using System; -using System.Globalization; - -namespace MeshDecimator.Math -{ - /// - /// A double precision 4D vector. - /// - public struct Vector4d : IEquatable - { - #region Static Read-Only - /// - /// The zero vector. - /// - public static readonly Vector4d zero = new Vector4d(0, 0, 0, 0); - #endregion - - #region Consts - /// - /// The vector epsilon. - /// - public const double Epsilon = double.Epsilon; - #endregion - - #region Fields - /// - /// The x component. - /// - public double x; - /// - /// The y component. - /// - public double y; - /// - /// The z component. - /// - public double z; - /// - /// The w component. - /// - public double w; - #endregion - - #region Properties - /// - /// Gets the magnitude of this vector. - /// - public double Magnitude - { - get { return System.Math.Sqrt(x * x + y * y + z * z + w * w); } - } - - /// - /// Gets the squared magnitude of this vector. - /// - public double MagnitudeSqr - { - get { return (x * x + y * y + z * z + w * w); } - } - - /// - /// Gets a normalized vector from this vector. - /// - public Vector4d Normalized - { - get - { - Vector4d result; - Normalize(ref this, out result); - return result; - } - } - - /// - /// Gets or sets a specific component by index in this vector. - /// - /// The component index. - public double this[int index] - { - get - { - switch (index) - { - case 0: - return x; - case 1: - return y; - case 2: - return z; - case 3: - return w; - default: - throw new IndexOutOfRangeException("Invalid Vector4d index!"); - } - } - set - { - switch (index) - { - case 0: - x = value; - break; - case 1: - y = value; - break; - case 2: - z = value; - break; - case 3: - w = value; - break; - default: - throw new IndexOutOfRangeException("Invalid Vector4d index!"); - } - } - } - #endregion - - #region Constructor - /// - /// Creates a new vector with one value for all components. - /// - /// The value. - public Vector4d(double value) - { - this.x = value; - this.y = value; - this.z = value; - this.w = value; - } - - /// - /// Creates a new vector. - /// - /// The x value. - /// The y value. - /// The z value. - /// The w value. - public Vector4d(double x, double y, double z, double w) - { - this.x = x; - this.y = y; - this.z = z; - this.w = w; - } - #endregion - - #region Operators - /// - /// Adds two vectors. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static Vector4d operator +(Vector4d a, Vector4d b) - { - return new Vector4d(a.x + b.x, a.y + b.y, a.z + b.z, a.w + b.w); - } - - /// - /// Subtracts two vectors. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static Vector4d operator -(Vector4d a, Vector4d b) - { - return new Vector4d(a.x - b.x, a.y - b.y, a.z - b.z, a.w - b.w); - } - - /// - /// Scales the vector uniformly. - /// - /// The vector. - /// The scaling value. - /// The resulting vector. - public static Vector4d operator *(Vector4d a, double d) - { - return new Vector4d(a.x * d, a.y * d, a.z * d, a.w * d); - } - - /// - /// Scales the vector uniformly. - /// - /// The scaling value. - /// The vector. - /// The resulting vector. - public static Vector4d operator *(double d, Vector4d a) - { - return new Vector4d(a.x * d, a.y * d, a.z * d, a.w * d); - } - - /// - /// Divides the vector with a float. - /// - /// The vector. - /// The dividing float value. - /// The resulting vector. - public static Vector4d operator /(Vector4d a, double d) - { - return new Vector4d(a.x / d, a.y / d, a.z / d, a.w / d); - } - - /// - /// Subtracts the vector from a zero vector. - /// - /// The vector. - /// The resulting vector. - public static Vector4d operator -(Vector4d a) - { - return new Vector4d(-a.x, -a.y, -a.z, -a.w); - } - - /// - /// Returns if two vectors equals eachother. - /// - /// The left hand side vector. - /// The right hand side vector. - /// If equals. - public static bool operator ==(Vector4d lhs, Vector4d rhs) - { - return (lhs - rhs).MagnitudeSqr < Epsilon; - } - - /// - /// Returns if two vectors don't equal eachother. - /// - /// The left hand side vector. - /// The right hand side vector. - /// If not equals. - public static bool operator !=(Vector4d lhs, Vector4d rhs) - { - return (lhs - rhs).MagnitudeSqr >= Epsilon; - } - - /// - /// Implicitly converts from a single-precision vector into a double-precision vector. - /// - /// The single-precision vector. - public static implicit operator Vector4d(Vector4 v) - { - return new Vector4d(v.x, v.y, v.z, v.w); - } - - /// - /// Implicitly converts from an integer vector into a double-precision vector. - /// - /// The integer vector. - public static implicit operator Vector4d(Vector4i v) - { - return new Vector4d(v.x, v.y, v.z, v.w); - } - #endregion - - #region Public Methods - #region Instance - /// - /// Set x, y and z components of an existing vector. - /// - /// The x value. - /// The y value. - /// The z value. - /// The w value. - public void Set(double x, double y, double z, double w) - { - this.x = x; - this.y = y; - this.z = z; - this.w = w; - } - - /// - /// Multiplies with another vector component-wise. - /// - /// The vector to multiply with. - public void Scale(ref Vector4d scale) - { - x *= scale.x; - y *= scale.y; - z *= scale.z; - w *= scale.w; - } - - /// - /// Normalizes this vector. - /// - public void Normalize() - { - double mag = this.Magnitude; - if (mag > Epsilon) - { - x /= mag; - y /= mag; - z /= mag; - w /= mag; - } - else - { - x = y = z = w = 0; - } - } - - /// - /// Clamps this vector between a specific range. - /// - /// The minimum component value. - /// The maximum component value. - public void Clamp(double min, double max) - { - if (x < min) x = min; - else if (x > max) x = max; - - if (y < min) y = min; - else if (y > max) y = max; - - if (z < min) z = min; - else if (z > max) z = max; - - if (w < min) w = min; - else if (w > max) w = max; - } - #endregion - - #region Object - /// - /// Returns a hash code for this vector. - /// - /// The hash code. - public override int GetHashCode() - { - return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2 ^ w.GetHashCode() >> 1; - } - - /// - /// Returns if this vector is equal to another one. - /// - /// The other vector to compare to. - /// If equals. - public override bool Equals(object other) - { - if (!(other is Vector4d)) - { - return false; - } - Vector4d vector = (Vector4d)other; - return (x == vector.x && y == vector.y && z == vector.z && w == vector.w); - } - - /// - /// Returns if this vector is equal to another one. - /// - /// The other vector to compare to. - /// If equals. - public bool Equals(Vector4d other) - { - return (x == other.x && y == other.y && z == other.z && w == other.w); - } - - /// - /// Returns a nicely formatted string for this vector. - /// - /// The string. - public override string ToString() - { - return string.Format("({0}, {1}, {2}, {3})", - x.ToString("F1", CultureInfo.InvariantCulture), - y.ToString("F1", CultureInfo.InvariantCulture), - z.ToString("F1", CultureInfo.InvariantCulture), - w.ToString("F1", CultureInfo.InvariantCulture)); - } - - /// - /// Returns a nicely formatted string for this vector. - /// - /// The float format. - /// The string. - public string ToString(string format) - { - return string.Format("({0}, {1}, {2}, {3})", - x.ToString(format, CultureInfo.InvariantCulture), - y.ToString(format, CultureInfo.InvariantCulture), - z.ToString(format, CultureInfo.InvariantCulture), - w.ToString(format, CultureInfo.InvariantCulture)); - } - #endregion - - #region Static - /// - /// Dot Product of two vectors. - /// - /// The left hand side vector. - /// The right hand side vector. - public static double Dot(ref Vector4d lhs, ref Vector4d rhs) - { - return lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z + lhs.w * rhs.w; - } - - /// - /// Performs a linear interpolation between two vectors. - /// - /// The vector to interpolate from. - /// The vector to interpolate to. - /// The time fraction. - /// The resulting vector. - public static void Lerp(ref Vector4d a, ref Vector4d b, double t, out Vector4d result) - { - result = new Vector4d(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t, a.w + (b.w - a.w) * t); - } - - /// - /// Multiplies two vectors component-wise. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static void Scale(ref Vector4d a, ref Vector4d b, out Vector4d result) - { - result = new Vector4d(a.x * b.x, a.y * b.y, a.z * b.z, a.w * b.w); - } - - /// - /// Normalizes a vector. - /// - /// The vector to normalize. - /// The resulting normalized vector. - public static void Normalize(ref Vector4d value, out Vector4d result) - { - double mag = value.Magnitude; - if (mag > Epsilon) - { - result = new Vector4d(value.x / mag, value.y / mag, value.z / mag, value.w / mag); - } - else - { - result = Vector4d.zero; - } - } - #endregion - #endregion - } -} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector4i.cs b/LightlessSync/ThirdParty/MeshDecimator/Math/Vector4i.cs deleted file mode 100644 index cc52459..0000000 --- a/LightlessSync/ThirdParty/MeshDecimator/Math/Vector4i.cs +++ /dev/null @@ -1,388 +0,0 @@ -#region License -/* -MIT License - -Copyright(c) 2017-2018 Mattias Edlund - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ -#endregion - -using System; -using System.Globalization; - -namespace MeshDecimator.Math -{ - /// - /// A 4D integer vector. - /// - public struct Vector4i : IEquatable - { - #region Static Read-Only - /// - /// The zero vector. - /// - public static readonly Vector4i zero = new Vector4i(0, 0, 0, 0); - #endregion - - #region Fields - /// - /// The x component. - /// - public int x; - /// - /// The y component. - /// - public int y; - /// - /// The z component. - /// - public int z; - /// - /// The w component. - /// - public int w; - #endregion - - #region Properties - /// - /// Gets the magnitude of this vector. - /// - public int Magnitude - { - get { return (int)System.Math.Sqrt(x * x + y * y + z * z + w * w); } - } - - /// - /// Gets the squared magnitude of this vector. - /// - public int MagnitudeSqr - { - get { return (x * x + y * y + z * z + w * w); } - } - - /// - /// Gets or sets a specific component by index in this vector. - /// - /// The component index. - public int this[int index] - { - get - { - switch (index) - { - case 0: - return x; - case 1: - return y; - case 2: - return z; - case 3: - return w; - default: - throw new IndexOutOfRangeException("Invalid Vector4i index!"); - } - } - set - { - switch (index) - { - case 0: - x = value; - break; - case 1: - y = value; - break; - case 2: - z = value; - break; - case 3: - w = value; - break; - default: - throw new IndexOutOfRangeException("Invalid Vector4i index!"); - } - } - } - #endregion - - #region Constructor - /// - /// Creates a new vector with one value for all components. - /// - /// The value. - public Vector4i(int value) - { - this.x = value; - this.y = value; - this.z = value; - this.w = value; - } - - /// - /// Creates a new vector. - /// - /// The x value. - /// The y value. - /// The z value. - /// The w value. - public Vector4i(int x, int y, int z, int w) - { - this.x = x; - this.y = y; - this.z = z; - this.w = w; - } - #endregion - - #region Operators - /// - /// Adds two vectors. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static Vector4i operator +(Vector4i a, Vector4i b) - { - return new Vector4i(a.x + b.x, a.y + b.y, a.z + b.z, a.w + b.w); - } - - /// - /// Subtracts two vectors. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static Vector4i operator -(Vector4i a, Vector4i b) - { - return new Vector4i(a.x - b.x, a.y - b.y, a.z - b.z, a.w - b.w); - } - - /// - /// Scales the vector uniformly. - /// - /// The vector. - /// The scaling value. - /// The resulting vector. - public static Vector4i operator *(Vector4i a, int d) - { - return new Vector4i(a.x * d, a.y * d, a.z * d, a.w * d); - } - - /// - /// Scales the vector uniformly. - /// - /// The scaling value. - /// The vector. - /// The resulting vector. - public static Vector4i operator *(int d, Vector4i a) - { - return new Vector4i(a.x * d, a.y * d, a.z * d, a.w * d); - } - - /// - /// Divides the vector with a float. - /// - /// The vector. - /// The dividing float value. - /// The resulting vector. - public static Vector4i operator /(Vector4i a, int d) - { - return new Vector4i(a.x / d, a.y / d, a.z / d, a.w / d); - } - - /// - /// Subtracts the vector from a zero vector. - /// - /// The vector. - /// The resulting vector. - public static Vector4i operator -(Vector4i a) - { - return new Vector4i(-a.x, -a.y, -a.z, -a.w); - } - - /// - /// Returns if two vectors equals eachother. - /// - /// The left hand side vector. - /// The right hand side vector. - /// If equals. - public static bool operator ==(Vector4i lhs, Vector4i rhs) - { - return (lhs.x == rhs.x && lhs.y == rhs.y && lhs.z == rhs.z && lhs.w == rhs.w); - } - - /// - /// Returns if two vectors don't equal eachother. - /// - /// The left hand side vector. - /// The right hand side vector. - /// If not equals. - public static bool operator !=(Vector4i lhs, Vector4i rhs) - { - return (lhs.x != rhs.x || lhs.y != rhs.y || lhs.z != rhs.z || lhs.w != rhs.w); - } - - /// - /// Explicitly converts from a single-precision vector into an integer vector. - /// - /// The single-precision vector. - public static explicit operator Vector4i(Vector4 v) - { - return new Vector4i((int)v.x, (int)v.y, (int)v.z, (int)v.w); - } - - /// - /// Explicitly converts from a double-precision vector into an integer vector. - /// - /// The double-precision vector. - public static explicit operator Vector4i(Vector4d v) - { - return new Vector4i((int)v.x, (int)v.y, (int)v.z, (int)v.w); - } - #endregion - - #region Public Methods - #region Instance - /// - /// Set x, y and z components of an existing vector. - /// - /// The x value. - /// The y value. - /// The z value. - /// The w value. - public void Set(int x, int y, int z, int w) - { - this.x = x; - this.y = y; - this.z = z; - this.w = w; - } - - /// - /// Multiplies with another vector component-wise. - /// - /// The vector to multiply with. - public void Scale(ref Vector4i scale) - { - x *= scale.x; - y *= scale.y; - z *= scale.z; - w *= scale.w; - } - - /// - /// Clamps this vector between a specific range. - /// - /// The minimum component value. - /// The maximum component value. - public void Clamp(int min, int max) - { - if (x < min) x = min; - else if (x > max) x = max; - - if (y < min) y = min; - else if (y > max) y = max; - - if (z < min) z = min; - else if (z > max) z = max; - - if (w < min) w = min; - else if (w > max) w = max; - } - #endregion - - #region Object - /// - /// Returns a hash code for this vector. - /// - /// The hash code. - public override int GetHashCode() - { - return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2 ^ w.GetHashCode() >> 1; - } - - /// - /// Returns if this vector is equal to another one. - /// - /// The other vector to compare to. - /// If equals. - public override bool Equals(object other) - { - if (!(other is Vector4i)) - { - return false; - } - Vector4i vector = (Vector4i)other; - return (x == vector.x && y == vector.y && z == vector.z && w == vector.w); - } - - /// - /// Returns if this vector is equal to another one. - /// - /// The other vector to compare to. - /// If equals. - public bool Equals(Vector4i other) - { - return (x == other.x && y == other.y && z == other.z && w == other.w); - } - - /// - /// Returns a nicely formatted string for this vector. - /// - /// The string. - public override string ToString() - { - return string.Format("({0}, {1}, {2}, {3})", - x.ToString(CultureInfo.InvariantCulture), - y.ToString(CultureInfo.InvariantCulture), - z.ToString(CultureInfo.InvariantCulture), - w.ToString(CultureInfo.InvariantCulture)); - } - - /// - /// Returns a nicely formatted string for this vector. - /// - /// The integer format. - /// The string. - public string ToString(string format) - { - return string.Format("({0}, {1}, {2}, {3})", - x.ToString(format, CultureInfo.InvariantCulture), - y.ToString(format, CultureInfo.InvariantCulture), - z.ToString(format, CultureInfo.InvariantCulture), - w.ToString(format, CultureInfo.InvariantCulture)); - } - #endregion - - #region Static - /// - /// Multiplies two vectors component-wise. - /// - /// The first vector. - /// The second vector. - /// The resulting vector. - public static void Scale(ref Vector4i a, ref Vector4i b, out Vector4i result) - { - result = new Vector4i(a.x * b.x, a.y * b.y, a.z * b.z, a.w * b.w); - } - #endregion - #endregion - } -} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/Mesh.cs b/LightlessSync/ThirdParty/MeshDecimator/Mesh.cs deleted file mode 100644 index 416ad4e..0000000 --- a/LightlessSync/ThirdParty/MeshDecimator/Mesh.cs +++ /dev/null @@ -1,1006 +0,0 @@ -#region License -/* -MIT License - -Copyright(c) 2017-2018 Mattias Edlund - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ -#endregion - -using System; -using System.Collections.Generic; -using MeshDecimator.Math; - -namespace MeshDecimator -{ - /// - /// A mesh. - /// - public sealed class Mesh - { - #region Consts - /// - /// The count of supported UV channels. - /// - public const int UVChannelCount = 4; - #endregion - - #region Fields - private Vector3d[] vertices = null; - private int[][] indices = null; - private Vector3[] normals = null; - private Vector4[] tangents = null; - private Vector4[] tangents2 = null; - private Vector2[][] uvs2D = null; - private Vector3[][] uvs3D = null; - private Vector4[][] uvs4D = null; - private Vector4[] colors = null; - private BoneWeight[] boneWeights = null; - private float[] positionWs = null; - private float[] normalWs = null; - - private static readonly int[] emptyIndices = new int[0]; - #endregion - - #region Properties - /// - /// Gets the count of vertices of this mesh. - /// - public int VertexCount - { - get { return vertices.Length; } - } - - /// - /// Gets or sets the count of submeshes in this mesh. - /// - public int SubMeshCount - { - get { return indices.Length; } - set - { - if (value <= 0) - throw new ArgumentOutOfRangeException("value"); - - int[][] newIndices = new int[value][]; - Array.Copy(indices, 0, newIndices, 0, MathHelper.Min(indices.Length, newIndices.Length)); - indices = newIndices; - } - } - - /// - /// Gets the total count of triangles in this mesh. - /// - public int TriangleCount - { - get - { - int triangleCount = 0; - for (int i = 0; i < indices.Length; i++) - { - if (indices[i] != null) - { - triangleCount += indices[i].Length / 3; - } - } - return triangleCount; - } - } - - /// - /// Gets or sets the vertices for this mesh. Note that this resets all other vertex attributes. - /// - public Vector3d[] Vertices - { - get { return vertices; } - set - { - if (value == null) - throw new ArgumentNullException("value"); - - vertices = value; - ClearVertexAttributes(); - } - } - - /// - /// Gets or sets the combined indices for this mesh. Once set, the sub-mesh count gets set to 1. - /// - public int[] Indices - { - get - { - if (indices.Length == 1) - { - return indices[0] ?? emptyIndices; - } - else - { - List indexList = new List(TriangleCount * 3); - for (int i = 0; i < indices.Length; i++) - { - if (indices[i] != null) - { - indexList.AddRange(indices[i]); - } - } - return indexList.ToArray(); - } - } - set - { - if (value == null) - throw new ArgumentNullException("value"); - else if ((value.Length % 3) != 0) - throw new ArgumentException("The index count must be multiple by 3.", "value"); - - SubMeshCount = 1; - SetIndices(0, value); - } - } - - /// - /// Gets or sets the normals for this mesh. - /// - public Vector3[] Normals - { - get { return normals; } - set - { - if (value != null && value.Length != vertices.Length) - throw new ArgumentException(string.Format("The vertex normals must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length)); - - normals = value; - } - } - - /// - /// Gets or sets the position W components for this mesh. - /// - public float[] PositionWs - { - get { return positionWs; } - set - { - if (value != null && value.Length != vertices.Length) - throw new ArgumentException(string.Format("The position Ws must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length)); - - positionWs = value; - } - } - - /// - /// Gets or sets the normal W components for this mesh. - /// - public float[] NormalWs - { - get { return normalWs; } - set - { - if (value != null && value.Length != vertices.Length) - throw new ArgumentException(string.Format("The normal Ws must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length)); - - normalWs = value; - } - } - - /// - /// Gets or sets the tangents for this mesh. - /// - public Vector4[] Tangents - { - get { return tangents; } - set - { - if (value != null && value.Length != vertices.Length) - throw new ArgumentException(string.Format("The vertex tangents must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length)); - - tangents = value; - } - } - - /// - /// Gets or sets the second tangent set for this mesh. - /// - public Vector4[] Tangents2 - { - get { return tangents2; } - set - { - if (value != null && value.Length != vertices.Length) - throw new ArgumentException(string.Format("The second vertex tangents must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length)); - - tangents2 = value; - } - } - - /// - /// Gets or sets the first UV set for this mesh. - /// - public Vector2[] UV1 - { - get { return GetUVs2D(0); } - set { SetUVs(0, value); } - } - - /// - /// Gets or sets the second UV set for this mesh. - /// - public Vector2[] UV2 - { - get { return GetUVs2D(1); } - set { SetUVs(1, value); } - } - - /// - /// Gets or sets the third UV set for this mesh. - /// - public Vector2[] UV3 - { - get { return GetUVs2D(2); } - set { SetUVs(2, value); } - } - - /// - /// Gets or sets the fourth UV set for this mesh. - /// - public Vector2[] UV4 - { - get { return GetUVs2D(3); } - set { SetUVs(3, value); } - } - - /// - /// Gets or sets the vertex colors for this mesh. - /// - public Vector4[] Colors - { - get { return colors; } - set - { - if (value != null && value.Length != vertices.Length) - throw new ArgumentException(string.Format("The vertex colors must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length)); - - colors = value; - } - } - - /// - /// Gets or sets the vertex bone weights for this mesh. - /// - public BoneWeight[] BoneWeights - { - get { return boneWeights; } - set - { - if (value != null && value.Length != vertices.Length) - throw new ArgumentException(string.Format("The vertex bone weights must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length)); - - boneWeights = value; - } - } - #endregion - - #region Constructor - /// - /// Creates a new mesh. - /// - /// The mesh vertices. - /// The mesh indices. - public Mesh(Vector3d[] vertices, int[] indices) - { - if (vertices == null) - throw new ArgumentNullException("vertices"); - else if (indices == null) - throw new ArgumentNullException("indices"); - else if ((indices.Length % 3) != 0) - throw new ArgumentException("The index count must be multiple by 3.", "indices"); - - this.vertices = vertices; - this.indices = new int[1][]; - this.indices[0] = indices; - } - - /// - /// Creates a new mesh. - /// - /// The mesh vertices. - /// The mesh indices. - public Mesh(Vector3d[] vertices, int[][] indices) - { - if (vertices == null) - throw new ArgumentNullException("vertices"); - else if (indices == null) - throw new ArgumentNullException("indices"); - - for (int i = 0; i < indices.Length; i++) - { - if (indices[i] != null && (indices[i].Length % 3) != 0) - throw new ArgumentException(string.Format("The index count must be multiple by 3 at sub-mesh index {0}.", i), "indices"); - } - - this.vertices = vertices; - this.indices = indices; - } - #endregion - - #region Private Methods - private void ClearVertexAttributes() - { - normals = null; - tangents = null; - tangents2 = null; - uvs2D = null; - uvs3D = null; - uvs4D = null; - colors = null; - boneWeights = null; - positionWs = null; - normalWs = null; - } - #endregion - - #region Public Methods - #region Recalculate Normals - /// - /// Recalculates the normals for this mesh smoothly. - /// - public void RecalculateNormals() - { - int vertexCount = vertices.Length; - Vector3[] normals = new Vector3[vertexCount]; - - int subMeshCount = this.indices.Length; - for (int subMeshIndex = 0; subMeshIndex < subMeshCount; subMeshIndex++) - { - int[] indices = this.indices[subMeshIndex]; - if (indices == null) - continue; - - int indexCount = indices.Length; - for (int i = 0; i < indexCount; i += 3) - { - int i0 = indices[i]; - int i1 = indices[i + 1]; - int i2 = indices[i + 2]; - - var v0 = (Vector3)vertices[i0]; - var v1 = (Vector3)vertices[i1]; - var v2 = (Vector3)vertices[i2]; - - var nx = v1 - v0; - var ny = v2 - v0; - Vector3 normal; - Vector3.Cross(ref nx, ref ny, out normal); - normal.Normalize(); - - normals[i0] += normal; - normals[i1] += normal; - normals[i2] += normal; - } - } - - for (int i = 0; i < vertexCount; i++) - { - normals[i].Normalize(); - } - - this.normals = normals; - } - #endregion - - #region Recalculate Tangents - /// - /// Recalculates the tangents for this mesh. - /// - public void RecalculateTangents() - { - // Make sure we have the normals first - if (normals == null) - return; - - // Also make sure that we have the first UV set - bool uvIs2D = (uvs2D != null && uvs2D[0] != null); - bool uvIs3D = (uvs3D != null && uvs3D[0] != null); - bool uvIs4D = (uvs4D != null && uvs4D[0] != null); - if (!uvIs2D && !uvIs3D && !uvIs4D) - return; - - int vertexCount = vertices.Length; - - var tangents = new Vector4[vertexCount]; - var tan1 = new Vector3[vertexCount]; - var tan2 = new Vector3[vertexCount]; - - Vector2[] uv2D = (uvIs2D ? uvs2D[0] : null); - Vector3[] uv3D = (uvIs3D ? uvs3D[0] : null); - Vector4[] uv4D = (uvIs4D ? uvs4D[0] : null); - - int subMeshCount = this.indices.Length; - for (int subMeshIndex = 0; subMeshIndex < subMeshCount; subMeshIndex++) - { - int[] indices = this.indices[subMeshIndex]; - if (indices == null) - continue; - - int indexCount = indices.Length; - for (int i = 0; i < indexCount; i += 3) - { - int i0 = indices[i]; - int i1 = indices[i + 1]; - int i2 = indices[i + 2]; - - var v0 = vertices[i0]; - var v1 = vertices[i1]; - var v2 = vertices[i2]; - - float s1, s2, t1, t2; - if (uvIs2D) - { - var w0 = uv2D[i0]; - var w1 = uv2D[i1]; - var w2 = uv2D[i2]; - s1 = w1.x - w0.x; - s2 = w2.x - w0.x; - t1 = w1.y - w0.y; - t2 = w2.y - w0.y; - } - else if (uvIs3D) - { - var w0 = uv3D[i0]; - var w1 = uv3D[i1]; - var w2 = uv3D[i2]; - s1 = w1.x - w0.x; - s2 = w2.x - w0.x; - t1 = w1.y - w0.y; - t2 = w2.y - w0.y; - } - else - { - var w0 = uv4D[i0]; - var w1 = uv4D[i1]; - var w2 = uv4D[i2]; - s1 = w1.x - w0.x; - s2 = w2.x - w0.x; - t1 = w1.y - w0.y; - t2 = w2.y - w0.y; - } - - - float x1 = (float)(v1.x - v0.x); - float x2 = (float)(v2.x - v0.x); - float y1 = (float)(v1.y - v0.y); - float y2 = (float)(v2.y - v0.y); - float z1 = (float)(v1.z - v0.z); - float z2 = (float)(v2.z - v0.z); - float r = 1f / (s1 * t2 - s2 * t1); - - var sdir = new Vector3((t2 * x1 - t1 * x2) * r, (t2 * y1 - t1 * y2) * r, (t2 * z1 - t1 * z2) * r); - var tdir = new Vector3((s1 * x2 - s2 * x1) * r, (s1 * y2 - s2 * y1) * r, (s1 * z2 - s2 * z1) * r); - - tan1[i0] += sdir; - tan1[i1] += sdir; - tan1[i2] += sdir; - tan2[i0] += tdir; - tan2[i1] += tdir; - tan2[i2] += tdir; - } - } - - for (int i = 0; i < vertexCount; i++) - { - var n = normals[i]; - var t = tan1[i]; - - var tmp = (t - n * Vector3.Dot(ref n, ref t)); - tmp.Normalize(); - - Vector3 c; - Vector3.Cross(ref n, ref t, out c); - float dot = Vector3.Dot(ref c, ref tan2[i]); - float w = (dot < 0f ? -1f : 1f); - tangents[i] = new Vector4(tmp.x, tmp.y, tmp.z, w); - } - - this.tangents = tangents; - } - #endregion - - #region Triangles - /// - /// Returns the count of triangles for a specific sub-mesh in this mesh. - /// - /// The sub-mesh index. - /// The triangle count. - public int GetTriangleCount(int subMeshIndex) - { - if (subMeshIndex < 0 || subMeshIndex >= indices.Length) - throw new IndexOutOfRangeException(); - - return indices[subMeshIndex].Length / 3; - } - - /// - /// Returns the triangle indices of a specific sub-mesh in this mesh. - /// - /// The sub-mesh index. - /// The triangle indices. - public int[] GetIndices(int subMeshIndex) - { - if (subMeshIndex < 0 || subMeshIndex >= indices.Length) - throw new IndexOutOfRangeException(); - - return indices[subMeshIndex] ?? emptyIndices; - } - - /// - /// Returns the triangle indices for all sub-meshes in this mesh. - /// - /// The sub-mesh triangle indices. - public int[][] GetSubMeshIndices() - { - var subMeshIndices = new int[indices.Length][]; - for (int subMeshIndex = 0; subMeshIndex < indices.Length; subMeshIndex++) - { - subMeshIndices[subMeshIndex] = indices[subMeshIndex] ?? emptyIndices; - } - return subMeshIndices; - } - - /// - /// Sets the triangle indices of a specific sub-mesh in this mesh. - /// - /// The sub-mesh index. - /// The triangle indices. - public void SetIndices(int subMeshIndex, int[] indices) - { - if (subMeshIndex < 0 || subMeshIndex >= this.indices.Length) - throw new IndexOutOfRangeException(); - else if (indices == null) - throw new ArgumentNullException("indices"); - else if ((indices.Length % 3) != 0) - throw new ArgumentException("The index count must be multiple by 3.", "indices"); - - this.indices[subMeshIndex] = indices; - } - #endregion - - #region UV Sets - #region Getting - /// - /// Returns the UV dimension for a specific channel. - /// - /// - /// The UV dimension count. - public int GetUVDimension(int channel) - { - if (channel < 0 || channel >= UVChannelCount) - throw new ArgumentOutOfRangeException("channel"); - - if (uvs2D != null && uvs2D[channel] != null) - { - return 2; - } - else if (uvs3D != null && uvs3D[channel] != null) - { - return 3; - } - else if (uvs4D != null && uvs4D[channel] != null) - { - return 4; - } - else - { - return 0; - } - } - - /// - /// Returns the UVs (2D) from a specific channel. - /// - /// The channel index. - /// The UVs. - public Vector2[] GetUVs2D(int channel) - { - if (channel < 0 || channel >= UVChannelCount) - throw new ArgumentOutOfRangeException("channel"); - - if (uvs2D != null && uvs2D[channel] != null) - { - return uvs2D[channel]; - } - else - { - return null; - } - } - - /// - /// Returns the UVs (3D) from a specific channel. - /// - /// The channel index. - /// The UVs. - public Vector3[] GetUVs3D(int channel) - { - if (channel < 0 || channel >= UVChannelCount) - throw new ArgumentOutOfRangeException("channel"); - - if (uvs3D != null && uvs3D[channel] != null) - { - return uvs3D[channel]; - } - else - { - return null; - } - } - - /// - /// Returns the UVs (4D) from a specific channel. - /// - /// The channel index. - /// The UVs. - public Vector4[] GetUVs4D(int channel) - { - if (channel < 0 || channel >= UVChannelCount) - throw new ArgumentOutOfRangeException("channel"); - - if (uvs4D != null && uvs4D[channel] != null) - { - return uvs4D[channel]; - } - else - { - return null; - } - } - - /// - /// Returns the UVs (2D) from a specific channel. - /// - /// The channel index. - /// The UVs. - public void GetUVs(int channel, List uvs) - { - if (channel < 0 || channel >= UVChannelCount) - throw new ArgumentOutOfRangeException("channel"); - else if (uvs == null) - throw new ArgumentNullException("uvs"); - - uvs.Clear(); - if (uvs2D != null && uvs2D[channel] != null) - { - var uvData = uvs2D[channel]; - if (uvData != null) - { - uvs.AddRange(uvData); - } - } - } - - /// - /// Returns the UVs (3D) from a specific channel. - /// - /// The channel index. - /// The UVs. - public void GetUVs(int channel, List uvs) - { - if (channel < 0 || channel >= UVChannelCount) - throw new ArgumentOutOfRangeException("channel"); - else if (uvs == null) - throw new ArgumentNullException("uvs"); - - uvs.Clear(); - if (uvs3D != null && uvs3D[channel] != null) - { - var uvData = uvs3D[channel]; - if (uvData != null) - { - uvs.AddRange(uvData); - } - } - } - - /// - /// Returns the UVs (4D) from a specific channel. - /// - /// The channel index. - /// The UVs. - public void GetUVs(int channel, List uvs) - { - if (channel < 0 || channel >= UVChannelCount) - throw new ArgumentOutOfRangeException("channel"); - else if (uvs == null) - throw new ArgumentNullException("uvs"); - - uvs.Clear(); - if (uvs4D != null && uvs4D[channel] != null) - { - var uvData = uvs4D[channel]; - if (uvData != null) - { - uvs.AddRange(uvData); - } - } - } - #endregion - - #region Setting - /// - /// Sets the UVs (2D) for a specific channel. - /// - /// The channel index. - /// The UVs. - public void SetUVs(int channel, Vector2[] uvs) - { - if (channel < 0 || channel >= UVChannelCount) - throw new ArgumentOutOfRangeException("channel"); - - if (uvs != null && uvs.Length > 0) - { - if (uvs.Length != vertices.Length) - throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvs.Length, vertices.Length)); - - if (uvs2D == null) - uvs2D = new Vector2[UVChannelCount][]; - - int uvCount = uvs.Length; - var uvSet = new Vector2[uvCount]; - uvs2D[channel] = uvSet; - uvs.CopyTo(uvSet, 0); - } - else - { - if (uvs2D != null) - { - uvs2D[channel] = null; - } - } - - if (uvs3D != null) - { - uvs3D[channel] = null; - } - if (uvs4D != null) - { - uvs4D[channel] = null; - } - } - - /// - /// Sets the UVs (3D) for a specific channel. - /// - /// The channel index. - /// The UVs. - public void SetUVs(int channel, Vector3[] uvs) - { - if (channel < 0 || channel >= UVChannelCount) - throw new ArgumentOutOfRangeException("channel"); - - if (uvs != null && uvs.Length > 0) - { - int uvCount = uvs.Length; - if (uvCount != vertices.Length) - throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvCount, vertices.Length), "uvs"); - - if (uvs3D == null) - uvs3D = new Vector3[UVChannelCount][]; - - var uvSet = new Vector3[uvCount]; - uvs3D[channel] = uvSet; - uvs.CopyTo(uvSet, 0); - } - else - { - if (uvs3D != null) - { - uvs3D[channel] = null; - } - } - - if (uvs2D != null) - { - uvs2D[channel] = null; - } - if (uvs4D != null) - { - uvs4D[channel] = null; - } - } - - /// - /// Sets the UVs (4D) for a specific channel. - /// - /// The channel index. - /// The UVs. - public void SetUVs(int channel, Vector4[] uvs) - { - if (channel < 0 || channel >= UVChannelCount) - throw new ArgumentOutOfRangeException("channel"); - - if (uvs != null && uvs.Length > 0) - { - int uvCount = uvs.Length; - if (uvCount != vertices.Length) - throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvCount, vertices.Length), "uvs"); - - if (uvs4D == null) - uvs4D = new Vector4[UVChannelCount][]; - - var uvSet = new Vector4[uvCount]; - uvs4D[channel] = uvSet; - uvs.CopyTo(uvSet, 0); - } - else - { - if (uvs4D != null) - { - uvs4D[channel] = null; - } - } - - if (uvs2D != null) - { - uvs2D[channel] = null; - } - if (uvs3D != null) - { - uvs3D[channel] = null; - } - } - - /// - /// Sets the UVs (2D) for a specific channel. - /// - /// The channel index. - /// The UVs. - public void SetUVs(int channel, List uvs) - { - if (channel < 0 || channel >= UVChannelCount) - throw new ArgumentOutOfRangeException("channel"); - - if (uvs != null && uvs.Count > 0) - { - int uvCount = uvs.Count; - if (uvCount != vertices.Length) - throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvCount, vertices.Length), "uvs"); - - if (uvs2D == null) - uvs2D = new Vector2[UVChannelCount][]; - - var uvSet = new Vector2[uvCount]; - uvs2D[channel] = uvSet; - uvs.CopyTo(uvSet, 0); - } - else - { - if (uvs2D != null) - { - uvs2D[channel] = null; - } - } - - if (uvs3D != null) - { - uvs3D[channel] = null; - } - if (uvs4D != null) - { - uvs4D[channel] = null; - } - } - - /// - /// Sets the UVs (3D) for a specific channel. - /// - /// The channel index. - /// The UVs. - public void SetUVs(int channel, List uvs) - { - if (channel < 0 || channel >= UVChannelCount) - throw new ArgumentOutOfRangeException("channel"); - - if (uvs != null && uvs.Count > 0) - { - int uvCount = uvs.Count; - if (uvCount != vertices.Length) - throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvCount, vertices.Length), "uvs"); - - if (uvs3D == null) - uvs3D = new Vector3[UVChannelCount][]; - - var uvSet = new Vector3[uvCount]; - uvs3D[channel] = uvSet; - uvs.CopyTo(uvSet, 0); - } - else - { - if (uvs3D != null) - { - uvs3D[channel] = null; - } - } - - if (uvs2D != null) - { - uvs2D[channel] = null; - } - if (uvs4D != null) - { - uvs4D[channel] = null; - } - } - - /// - /// Sets the UVs (4D) for a specific channel. - /// - /// The channel index. - /// The UVs. - public void SetUVs(int channel, List uvs) - { - if (channel < 0 || channel >= UVChannelCount) - throw new ArgumentOutOfRangeException("channel"); - - if (uvs != null && uvs.Count > 0) - { - int uvCount = uvs.Count; - if (uvCount != vertices.Length) - throw new ArgumentException(string.Format("The vertex UVs must be as many as the vertices. Assigned: {0} Require: {1}", uvCount, vertices.Length), "uvs"); - - if (uvs4D == null) - uvs4D = new Vector4[UVChannelCount][]; - - var uvSet = new Vector4[uvCount]; - uvs4D[channel] = uvSet; - uvs.CopyTo(uvSet, 0); - } - else - { - if (uvs4D != null) - { - uvs4D[channel] = null; - } - } - - if (uvs2D != null) - { - uvs2D[channel] = null; - } - if (uvs3D != null) - { - uvs3D[channel] = null; - } - } - #endregion - #endregion - - #region To String - /// - /// Returns the text-representation of this mesh. - /// - /// The text-representation. - public override string ToString() - { - return string.Format("Vertices: {0}", vertices.Length); - } - #endregion - #endregion - } -} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/MeshDecimator/MeshDecimation.cs b/LightlessSync/ThirdParty/MeshDecimator/MeshDecimation.cs deleted file mode 100644 index cb13fe8..0000000 --- a/LightlessSync/ThirdParty/MeshDecimator/MeshDecimation.cs +++ /dev/null @@ -1,180 +0,0 @@ -#region License -/* -MIT License - -Copyright(c) 2017-2018 Mattias Edlund - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ -#endregion - -using System; -using MeshDecimator.Algorithms; - -namespace MeshDecimator -{ - #region Algorithm - /// - /// The decimation algorithms. - /// - public enum Algorithm - { - /// - /// The default algorithm. - /// - Default, - /// - /// The fast quadric mesh simplification algorithm. - /// - FastQuadricMesh - } - #endregion - - /// - /// The mesh decimation API. - /// - public static class MeshDecimation - { - #region Public Methods - #region Create Algorithm - /// - /// Creates a specific decimation algorithm. - /// - /// The desired algorithm. - /// The decimation algorithm. - public static DecimationAlgorithm CreateAlgorithm(Algorithm algorithm) - { - DecimationAlgorithm alg = null; - - switch (algorithm) - { - case Algorithm.Default: - case Algorithm.FastQuadricMesh: - alg = new FastQuadricMeshSimplification(); - break; - default: - throw new ArgumentException("The specified algorithm is not supported.", "algorithm"); - } - - return alg; - } - #endregion - - #region Decimate Mesh - /// - /// Decimates a mesh. - /// - /// The mesh to decimate. - /// The target triangle count. - /// The decimated mesh. - public static Mesh DecimateMesh(Mesh mesh, int targetTriangleCount) - { - return DecimateMesh(Algorithm.Default, mesh, targetTriangleCount); - } - - /// - /// Decimates a mesh. - /// - /// The desired algorithm. - /// The mesh to decimate. - /// The target triangle count. - /// The decimated mesh. - public static Mesh DecimateMesh(Algorithm algorithm, Mesh mesh, int targetTriangleCount) - { - if (mesh == null) - throw new ArgumentNullException("mesh"); - - var decimationAlgorithm = CreateAlgorithm(algorithm); - return DecimateMesh(decimationAlgorithm, mesh, targetTriangleCount); - } - - /// - /// Decimates a mesh. - /// - /// The decimation algorithm. - /// The mesh to decimate. - /// The target triangle count. - /// The decimated mesh. - public static Mesh DecimateMesh(DecimationAlgorithm algorithm, Mesh mesh, int targetTriangleCount) - { - if (algorithm == null) - throw new ArgumentNullException("algorithm"); - else if (mesh == null) - throw new ArgumentNullException("mesh"); - - int currentTriangleCount = mesh.TriangleCount; - if (targetTriangleCount > currentTriangleCount) - targetTriangleCount = currentTriangleCount; - else if (targetTriangleCount < 0) - targetTriangleCount = 0; - - algorithm.Initialize(mesh); - algorithm.DecimateMesh(targetTriangleCount); - return algorithm.ToMesh(); - } - #endregion - - #region Decimate Mesh Lossless - /// - /// Decimates a mesh without losing any quality. - /// - /// The mesh to decimate. - /// The decimated mesh. - public static Mesh DecimateMeshLossless(Mesh mesh) - { - return DecimateMeshLossless(Algorithm.Default, mesh); - } - - /// - /// Decimates a mesh without losing any quality. - /// - /// The desired algorithm. - /// The mesh to decimate. - /// The decimated mesh. - public static Mesh DecimateMeshLossless(Algorithm algorithm, Mesh mesh) - { - if (mesh == null) - throw new ArgumentNullException("mesh"); - - var decimationAlgorithm = CreateAlgorithm(algorithm); - return DecimateMeshLossless(decimationAlgorithm, mesh); - } - - /// - /// Decimates a mesh without losing any quality. - /// - /// The decimation algorithm. - /// The mesh to decimate. - /// The decimated mesh. - public static Mesh DecimateMeshLossless(DecimationAlgorithm algorithm, Mesh mesh) - { - if (algorithm == null) - throw new ArgumentNullException("algorithm"); - else if (mesh == null) - throw new ArgumentNullException("mesh"); - - int currentTriangleCount = mesh.TriangleCount; - algorithm.Initialize(mesh); - algorithm.DecimateMeshLossless(); - return algorithm.ToMesh(); - } - #endregion - #endregion - } -} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/Decimate.cs b/LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/Decimate.cs new file mode 100644 index 0000000..a69485b --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/Decimate.cs @@ -0,0 +1,1325 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace Nanomesh +{ + public partial class DecimateModifier + { + // Heuristics + internal static bool UpdateFarNeighbors = false; + internal static bool UpdateMinsOnCollapse = true; + internal static float MergeNormalsThresholdDegrees = 90; + internal static float NormalSimilarityThresholdDegrees = 60; + internal static float CollapseToMidpointPenalty = 0.4716252f; + internal static bool CollapseToEndpointsOnly = false; + internal static float UvSimilarityThreshold = 0.02f; + internal static float UvSeamAngleCos = 0.99f; + internal static bool BlockUvSeamVertices = true; + internal static float BoneWeightSimilarityThreshold = 0.85f; + internal static bool LimitCollapseEdgeLength = false; + internal static float MaxCollapseEdgeLength = float.PositiveInfinity; + internal static bool AllowBoundaryCollapses = false; + internal static float BodyCollisionPenetrationFactor = 0.75f; + + // Constants + private const double _DeterminantEpsilon = 0.001f; + private const float _MinTriangleAreaRatio = 0.05f; + private const float _UvDirEpsilonSq = 1e-12f; + private const double _OFFSET_HARD = 1e6; + private const double _OFFSET_NOCOLLAPSE = 1e300; + + // Instance + private ConnectedMesh _mesh; + private SymmetricMatrix[] _matrices; + private FastHashSet _pairs; + private LinkedHashSet _mins; + private int _lastProgress = int.MinValue; + private int _initialTriangleCount; + private float _mergeNormalsThresholdCos = MathF.Cos(MergeNormalsThresholdDegrees * MathF.PI / 180f); + private float _normalSimilarityThresholdCos = MathF.Cos(NormalSimilarityThresholdDegrees * MathF.PI / 180f); + private int _evaluatedEdges; + private int _collapsedEdges; + private int _rejectedBoneWeights; + private int _rejectedTopology; + private int _rejectedInversion; + private int _rejectedDegenerate; + private int _rejectedArea; + private int _rejectedFlip; + private int _rejectedBodyCollision; + private float[]? _bodyDistanceSq; + private float _bodyDistanceThresholdSq; + private Func? _bodyDistanceSqEvaluator; + private bool[]? _protectedVertices; + + public ConnectedMesh Mesh => _mesh; + + public DecimationStats GetStats() + => new DecimationStats( + _evaluatedEdges, + _collapsedEdges, + _rejectedBoneWeights, + _rejectedTopology, + _rejectedInversion, + _rejectedDegenerate, + _rejectedArea, + _rejectedFlip, + _rejectedBodyCollision); + + public void SetBodyCollision(float[]? bodyDistanceSq, float bodyDistanceThresholdSq, Func? bodyDistanceSqEvaluator = null) + { + _bodyDistanceSq = bodyDistanceSq; + _bodyDistanceThresholdSq = bodyDistanceThresholdSq; + _bodyDistanceSqEvaluator = bodyDistanceSqEvaluator; + } + + public void SetProtectedVertices(bool[]? protectedVertices) + { + _protectedVertices = protectedVertices; + } + + public void Initialize(ConnectedMesh mesh) + { + _mesh = mesh; + ResetStats(); + + _initialTriangleCount = mesh.FaceCount; + + _matrices = new SymmetricMatrix[mesh.positions.Length]; + _pairs = new FastHashSet(); + _mins = new LinkedHashSet(); + + InitializePairs(); + + for (int p = 0; p < _mesh.PositionToNode.Length; p++) + { + if (_mesh.PositionToNode[p] != -1) + CalculateQuadric(p); + } + + foreach (EdgeCollapse pair in _pairs) + { + CalculateError(pair); + } + } + + public void DecimateToError(float maximumError) + { + while (GetPairWithMinimumError().error <= maximumError && _pairs.Count > 0) + { + Iterate(); + } + } + + public void DecimateToRatio(float targetTriangleRatio) + { + targetTriangleRatio = MathF.Clamp(targetTriangleRatio, 0f, 1f); + DecimateToPolycount((int)MathF.Round(targetTriangleRatio * _mesh.FaceCount)); + } + + public void DecimatePolycount(int polycount) + { + DecimateToPolycount((int)MathF.Round(_mesh.FaceCount - polycount)); + } + + public void DecimateToPolycount(int targetTriangleCount) + { + while (_mesh.FaceCount > targetTriangleCount && _pairs.Count > 0) + { + Iterate(); + + int progress = (int)MathF.Round(100f * (_initialTriangleCount - _mesh.FaceCount) / (_initialTriangleCount - targetTriangleCount)); + if (progress >= _lastProgress + 10) + { + _lastProgress = progress; + } + } + } + + public void Iterate() + { + EdgeCollapse pair = GetPairWithMinimumError(); + while (pair != null && pair.error >= _OFFSET_NOCOLLAPSE) + { + _pairs.Remove(pair); + _mins.Remove(pair); + pair = GetPairWithMinimumError(); + } + + if (pair == null) + return; + + Debug.Assert(_mesh.CheckEdge(_mesh.PositionToNode[pair.posA], _mesh.PositionToNode[pair.posB])); + + _pairs.Remove(pair); + _mins.Remove(pair); + + CollapseEdge(pair); + } + + public double GetMinimumError() + { + return GetPairWithMinimumError()?.error ?? double.PositiveInfinity; + } + + private EdgeCollapse GetPairWithMinimumError() + { + if (_mins.Count == 0) + ComputeMins(); + + LinkedHashSet.LinkedHashNode edge = _mins.First; + + return edge?.Value; + } + + private int MinsCount => MathF.Clamp(500, 0, _pairs.Count); + + private void ComputeMins() + { + _mins = new LinkedHashSet(_pairs.OrderBy(x => x).Take(MinsCount)); + } + + private void InitializePairs() + { + _pairs.Clear(); + _mins.Clear(); + + for (int p = 0; p < _mesh.PositionToNode.Length; p++) + { + int nodeIndex = _mesh.PositionToNode[p]; + if (nodeIndex < 0) + { + continue; + } + + int sibling = nodeIndex; + do + { + int firstRelative = _mesh.nodes[sibling].relative; + int secondRelative = _mesh.nodes[firstRelative].relative; + + EdgeCollapse pair = new EdgeCollapse(_mesh.nodes[firstRelative].position, _mesh.nodes[secondRelative].position); + + _pairs.Add(pair); + + Debug.Assert(_mesh.CheckEdge(_mesh.PositionToNode[pair.posA], _mesh.PositionToNode[pair.posB])); + + } while ((sibling = _mesh.nodes[sibling].sibling) != nodeIndex); + } + } + + private void CalculateQuadric(int position) + { + int nodeIndex = _mesh.PositionToNode[position]; + + Debug.Assert(nodeIndex >= 0); + Debug.Assert(!_mesh.nodes[nodeIndex].IsRemoved); + + SymmetricMatrix symmetricMatrix = new SymmetricMatrix(); + + int sibling = nodeIndex; + do + { + Debug.Assert(_mesh.CheckRelatives(sibling)); + + Vector3 faceNormal = _mesh.GetFaceNormal(sibling); + double dot = Vector3.Dot(-faceNormal, _mesh.positions[_mesh.nodes[sibling].position]); + symmetricMatrix += new SymmetricMatrix(faceNormal.x, faceNormal.y, faceNormal.z, dot); + + } while ((sibling = _mesh.nodes[sibling].sibling) != nodeIndex); + + _matrices[position] = symmetricMatrix; + } + + private readonly HashSet _adjacentEdges = new HashSet(); + private readonly HashSet _adjacentEdgesA = new HashSet(); + private readonly HashSet _adjacentEdgesB = new HashSet(); + + private IEnumerable GetAdjacentPositions(int nodeIndex, int nodeAvoid) + { + _adjacentEdges.Clear(); + + int posToAvoid = _mesh.nodes[nodeAvoid].position; + + int sibling = nodeIndex; + do + { + for (int relative = sibling; (relative = _mesh.nodes[relative].relative) != sibling;) + { + if (_mesh.nodes[relative].position != posToAvoid) + { + _adjacentEdges.Add(_mesh.nodes[relative].position); + } + } + } while ((sibling = _mesh.nodes[sibling].sibling) != nodeIndex); + + return _adjacentEdges; + } + + private void FillAdjacentPositions(int nodeIndex, int nodeAvoid, HashSet output) + { + output.Clear(); + + int posToAvoid = _mesh.nodes[nodeAvoid].position; + + int sibling = nodeIndex; + do + { + for (int relative = sibling; (relative = _mesh.nodes[relative].relative) != sibling;) + { + if (_mesh.nodes[relative].position != posToAvoid) + { + output.Add(_mesh.nodes[relative].position); + } + } + } while ((sibling = _mesh.nodes[sibling].sibling) != nodeIndex); + } + + private void FillAdjacentPositionsByPos(int nodeIndex, int posToAvoid, HashSet output) + { + output.Clear(); + + int sibling = nodeIndex; + do + { + for (int relative = sibling; (relative = _mesh.nodes[relative].relative) != sibling;) + { + int pos = _mesh.nodes[relative].position; + if (pos != posToAvoid) + { + output.Add(pos); + } + } + } while ((sibling = _mesh.nodes[sibling].sibling) != nodeIndex); + } + + private double GetEdgeTopo(EdgeCollapse edge) + { + if (edge.Weight == -1) + { + edge.SetWeight(_mesh.GetEdgeTopo(_mesh.PositionToNode[edge.posA], _mesh.PositionToNode[edge.posB])); + } + return edge.Weight; + } + + public static bool UseEdgeLength = true; + + private void CalculateError(EdgeCollapse pair) + { + Debug.Assert(_mesh.CheckEdge(_mesh.PositionToNode[pair.posA], _mesh.PositionToNode[pair.posB])); + + Vector3 posA = _mesh.positions[pair.posA]; + Vector3 posB = _mesh.positions[pair.posB]; + _evaluatedEdges++; + + if (ShouldBlockBoneWeightCollapse(pair.posA, pair.posB)) + { + _rejectedBoneWeights++; + pair.error = _OFFSET_NOCOLLAPSE; + return; + } + if (ShouldBlockNormalCollapse(pair.posA, pair.posB)) + { + _rejectedTopology++; + pair.error = _OFFSET_NOCOLLAPSE; + return; + } + if (ShouldBlockUvCollapse(pair.posA, pair.posB)) + { + _rejectedTopology++; + pair.error = _OFFSET_NOCOLLAPSE; + return; + } + if (IsProtectedVertex(pair.posA) || IsProtectedVertex(pair.posB)) + { + _rejectedBodyCollision++; + pair.error = _OFFSET_NOCOLLAPSE; + return; + } + + var edgeTopo = GetEdgeTopo(pair); + if (edgeTopo > 0d && !AllowBoundaryCollapses) + { + _rejectedTopology++; + pair.error = _OFFSET_NOCOLLAPSE; + return; + } + Vector3 posC = (posB + posA) / 2; + + int nodeA = _mesh.PositionToNode[pair.posA]; + int nodeB = _mesh.PositionToNode[pair.posB]; + if (!CollapsePreservesTopology(pair)) + { + _rejectedTopology++; + pair.error = _OFFSET_NOCOLLAPSE; + return; + } + if (!AllowBoundaryCollapses && (IsBoundaryVertex(nodeA) || IsBoundaryVertex(nodeB))) + { + _rejectedTopology++; + pair.error = _OFFSET_NOCOLLAPSE; + return; + } + + double errorCollapseToO; + Vector3 posO = Vector3.PositiveInfinity; + + // If a node is smooth (no hard edge connected, no uv break or no border), we can compute a quadric error + // Otherwise, we add up linear errors for every non smooth source. + // If both nodes of the edge are smooth, we can find the optimal position to collapse to by inverting the + // quadric matrix, otherwise, we pick the best between A, B, and the position in the middle, C. + + SymmetricMatrix q = _matrices[pair.posA] + _matrices[pair.posB]; + double det = q.DeterminantXYZ(); + + if (det > _DeterminantEpsilon || det < -_DeterminantEpsilon) + { + posO = new Vector3( + -1d / det * q.DeterminantX(), + +1d / det * q.DeterminantY(), + -1d / det * q.DeterminantZ()); + errorCollapseToO = ComputeVertexError(q, posO.x, posO.y, posO.z); + } + else + { + errorCollapseToO = _OFFSET_NOCOLLAPSE; + } + + double errorCollapseToA = ComputeVertexError(q, posA.x, posA.y, posA.z); + double errorCollapseToB = ComputeVertexError(q, posB.x, posB.y, posB.z); + double errorCollapseToC = ComputeVertexError(q, posC.x, posC.y, posC.z); + + int pA = _mesh.nodes[nodeA].position; + int pB = _mesh.nodes[nodeB].position; + + // We multiply by edge length to be agnotics with quadrics error. + // Otherwise it becomes too scale dependent + double length = (posB - posA).Length; + if (LimitCollapseEdgeLength && length > MaxCollapseEdgeLength) + { + _rejectedTopology++; + pair.error = _OFFSET_NOCOLLAPSE; + return; + } + + foreach (int pD in GetAdjacentPositions(nodeA, nodeB)) + { + Vector3 posD = _mesh.positions[pD]; + EdgeCollapse edge = new EdgeCollapse(pA, pD); + if (_pairs.TryGetValue(edge, out EdgeCollapse realEdge)) + { + double weight = GetEdgeTopo(realEdge); + errorCollapseToB += weight * length * ComputeLineicError(posB, posD, posA); + errorCollapseToC += weight * length * ComputeLineicError(posC, posD, posA); + } + } + + foreach (int pD in GetAdjacentPositions(nodeB, nodeA)) + { + Vector3 posD = _mesh.positions[pD]; + EdgeCollapse edge = new EdgeCollapse(pB, pD); + if (_pairs.TryGetValue(edge, out EdgeCollapse realEdge)) + { + double weight = GetEdgeTopo(realEdge); + errorCollapseToA += weight * length * ComputeLineicError(posA, posD, posB); + errorCollapseToC += weight * length * ComputeLineicError(posC, posD, posB); + } + } + + errorCollapseToC *= CollapseToMidpointPenalty; + + if (CollapseToEndpointsOnly) + { + errorCollapseToO = _OFFSET_NOCOLLAPSE; + errorCollapseToC = _OFFSET_NOCOLLAPSE; + } + + if (CollapseToEndpointsOnly && _bodyDistanceSq != null && _bodyDistanceThresholdSq > 0f) + { + var hasA = TryGetBodyDistanceSq(pair.posA, out var distASq); + var hasB = TryGetBodyDistanceSq(pair.posB, out var distBSq); + var nearA = hasA && distASq <= _bodyDistanceThresholdSq; + var nearB = hasB && distBSq <= _bodyDistanceThresholdSq; + + if (nearA && nearB) + { + if (distASq > distBSq) + { + errorCollapseToB = _OFFSET_NOCOLLAPSE; + } + else if (distBSq > distASq) + { + errorCollapseToA = _OFFSET_NOCOLLAPSE; + } + else + { + errorCollapseToA = _OFFSET_NOCOLLAPSE; + errorCollapseToB = _OFFSET_NOCOLLAPSE; + } + } + else + { + if (nearA) + { + errorCollapseToA = _OFFSET_NOCOLLAPSE; + } + + if (nearB) + { + errorCollapseToB = _OFFSET_NOCOLLAPSE; + } + } + + if (hasA && hasB) + { + if (distASq > distBSq) + { + errorCollapseToB = _OFFSET_NOCOLLAPSE; + } + else if (distBSq > distASq) + { + errorCollapseToA = _OFFSET_NOCOLLAPSE; + } + } + + if (errorCollapseToA >= _OFFSET_NOCOLLAPSE && errorCollapseToB >= _OFFSET_NOCOLLAPSE) + { + _rejectedBodyCollision++; + pair.error = _OFFSET_NOCOLLAPSE; + return; + } + } + + if (!CollapseToEndpointsOnly && IsPointNearBody((posA + posB) * 0.5)) + { + _rejectedBodyCollision++; + pair.error = _OFFSET_NOCOLLAPSE; + return; + } + + MathUtils.SelectMin( + errorCollapseToO, errorCollapseToA, errorCollapseToB, errorCollapseToC, + posO, posA, posB, posC, + out pair.error, out pair.result); + + pair.error = Math.Max(0d, pair.error); + + if (!CollapseWillInvert(pair)) + { + pair.error = _OFFSET_NOCOLLAPSE; + } + + // TODO : Make it insensitive to model scale + } + + private bool CollapsePreservesTopology(EdgeCollapse edge) + { + int nodeIndexA = _mesh.PositionToNode[edge.posA]; + int nodeIndexB = _mesh.PositionToNode[edge.posB]; + if (nodeIndexA < 0 || nodeIndexB < 0) + { + return true; + } + + FillAdjacentPositions(nodeIndexA, nodeIndexB, _adjacentEdgesA); + FillAdjacentPositions(nodeIndexB, nodeIndexA, _adjacentEdgesB); + + int shared = 0; + foreach (var neighbor in _adjacentEdgesA) + { + if (_adjacentEdgesB.Contains(neighbor)) + { + shared++; + if (shared > 2) + { + return false; + } + } + } + + return AllowBoundaryCollapses ? shared >= 1 : shared == 2; + } + + private bool IsBoundaryVertex(int nodeIndex) + { + if (nodeIndex < 0) + { + return false; + } + + int sibling = nodeIndex; + do + { + for (int relative = sibling; (relative = _mesh.nodes[relative].relative) != sibling;) + { + if (_mesh.GetEdgeTopo(sibling, relative) >= ConnectedMesh.EdgeBorderPenalty) + { + return true; + } + } + } while ((sibling = _mesh.nodes[sibling].sibling) != nodeIndex); + + return false; + } + + private bool ShouldBlockBoneWeightCollapse(int posA, int posB) + { + if (_mesh.attributes is not MetaAttributeList attrList) + { + return false; + } + + int nodeA = _mesh.PositionToNode[posA]; + int nodeB = _mesh.PositionToNode[posB]; + if (nodeA < 0 || nodeB < 0) + { + return false; + } + + bool hasWeights = false; + int siblingA = nodeA; + do + { + var attrA = (MetaAttribute)attrList[_mesh.nodes[siblingA].attribute]; + if ((attrA.attr0.flags & FfxivAttributeFlags.BoneWeights) != 0) + { + hasWeights = true; + int siblingB = nodeB; + do + { + var attrB = (MetaAttribute)attrList[_mesh.nodes[siblingB].attribute]; + if ((attrB.attr0.flags & FfxivAttributeFlags.BoneWeights) != 0 + && HasMatchingDominantBone(attrA.attr0.boneWeight, attrB.attr0.boneWeight) + && GetBoneWeightOverlapNormalized(attrA.attr0.boneWeight, attrB.attr0.boneWeight) >= BoneWeightSimilarityThreshold) + { + return false; + } + } while ((siblingB = _mesh.nodes[siblingB].sibling) != nodeB); + } + } while ((siblingA = _mesh.nodes[siblingA].sibling) != nodeA); + + return hasWeights; + } + + private bool ShouldBlockUvCollapse(int posA, int posB) + { + if (_mesh.attributes is not MetaAttributeList attrList) + { + return false; + } + + var attrA = ((MetaAttribute)attrList[posA]).attr0; + var attrB = ((MetaAttribute)attrList[posB]).attr0; + var flags = attrA.flags | attrB.flags; + if ((flags & FfxivAttributeFlags.Uv0) == 0) + { + return false; + } + + var isSeam = IsUvSeamEdge(attrA.uv0, attrB.uv0); + if (!isSeam) + { + if (BlockUvSeamVertices && (HasUvSeamAtVertex(posA, posB, attrList, attrA) || HasUvSeamAtVertex(posB, posA, attrList, attrB))) + { + return true; + } + + return false; + } + + if (!CheckUvSeamAngleAtVertex(posA, posB, attrList, attrA, attrB)) + { + return true; + } + + if (!CheckUvSeamAngleAtVertex(posB, posA, attrList, attrB, attrA)) + { + return true; + } + + return false; + } + + private bool ShouldBlockNormalCollapse(int posA, int posB) + { + if (_mesh.attributes is not MetaAttributeList attrList) + { + return false; + } + + var attrA = ((MetaAttribute)attrList[posA]).attr0; + var attrB = ((MetaAttribute)attrList[posB]).attr0; + if ((attrA.flags & FfxivAttributeFlags.Normal) == 0 || (attrB.flags & FfxivAttributeFlags.Normal) == 0) + { + return false; + } + + var dot = Vector3F.Dot(attrA.normal, attrB.normal); + return dot < _normalSimilarityThresholdCos; + } + + private static float UvDistanceSq(in Vector2F a, in Vector2F b) + { + var dx = a.x - b.x; + var dy = a.y - b.y; + return (dx * dx) + (dy * dy); + } + + private static bool IsUvSeamEdge(in Vector2F uvA, in Vector2F uvB) + { + var thresholdSq = UvSimilarityThreshold * UvSimilarityThreshold; + return UvDistanceSq(uvA, uvB) > thresholdSq; + } + + private bool HasUvSeamAtVertex(int posCenter, int posExclude, MetaAttributeList attrList, in FfxivVertexAttribute attrCenter) + { + int nodeCenter = _mesh.PositionToNode[posCenter]; + if (nodeCenter < 0) + { + return false; + } + + FillAdjacentPositionsByPos(nodeCenter, posExclude, _adjacentEdges); + foreach (int neighborPos in _adjacentEdges) + { + var attrNeighbor = ((MetaAttribute)attrList[neighborPos]).attr0; + if (((attrNeighbor.flags | attrCenter.flags) & FfxivAttributeFlags.Uv0) == 0) + { + continue; + } + + if (IsUvSeamEdge(attrCenter.uv0, attrNeighbor.uv0)) + { + return true; + } + } + + return false; + } + + private bool CheckUvSeamAngleAtVertex(int posCenter, int posOther, MetaAttributeList attrList, in FfxivVertexAttribute attrCenter, in FfxivVertexAttribute attrOther) + { + int nodeCenter = _mesh.PositionToNode[posCenter]; + if (nodeCenter < 0) + { + return true; + } + + FillAdjacentPositionsByPos(nodeCenter, posOther, _adjacentEdges); + + int seamEdges = 1; + int otherSeamPos = -1; + + foreach (int neighborPos in _adjacentEdges) + { + var attrNeighbor = ((MetaAttribute)attrList[neighborPos]).attr0; + if (((attrNeighbor.flags | attrCenter.flags) & FfxivAttributeFlags.Uv0) == 0) + { + continue; + } + + if (IsUvSeamEdge(attrCenter.uv0, attrNeighbor.uv0)) + { + seamEdges++; + otherSeamPos = neighborPos; + if (seamEdges > 2) + { + return false; + } + } + } + + if (otherSeamPos < 0) + { + return true; + } + + var attrOtherSeam = ((MetaAttribute)attrList[otherSeamPos]).attr0; + if (!TryNormalizeUvDirection(attrCenter.uv0, attrOther.uv0, out var dir1) + || !TryNormalizeUvDirection(attrCenter.uv0, attrOtherSeam.uv0, out var dir2)) + { + return false; + } + + var dot = (dir1.x * dir2.x) + (dir1.y * dir2.y); + return dot >= UvSeamAngleCos; + } + + private static bool TryNormalizeUvDirection(in Vector2F from, in Vector2F to, out Vector2F direction) + { + var dx = to.x - from.x; + var dy = to.y - from.y; + var lenSq = (dx * dx) + (dy * dy); + if (lenSq <= _UvDirEpsilonSq) + { + direction = default; + return false; + } + + var invLen = 1f / MathF.Sqrt(lenSq); + direction = new Vector2F(dx * invLen, dy * invLen); + return true; + } + + private bool TryGetBodyDistanceSq(int pos, out float distanceSq) + { + distanceSq = float.NaN; + if (_bodyDistanceSq == null) + { + return false; + } + + if ((uint)pos >= (uint)_bodyDistanceSq.Length) + { + return false; + } + + distanceSq = _bodyDistanceSq[pos]; + return !float.IsNaN(distanceSq); + } + + private static float GetBoneWeightOverlapNormalized(in BoneWeight a, in BoneWeight b) + { + var overlap = GetBoneWeightOverlap(a, b); + var sumA = GetBoneWeightSum(a); + var sumB = GetBoneWeightSum(b); + var denom = MathF.Max(sumA, sumB); + if (denom <= 1e-6f) + { + return 1f; + } + + return overlap / denom; + } + + private static bool HasMatchingDominantBone(in BoneWeight a, in BoneWeight b) + { + var dominantA = GetDominantBoneIndex(a); + if (dominantA < 0) + { + return true; + } + + var dominantB = GetDominantBoneIndex(b); + if (dominantB < 0) + { + return true; + } + + return dominantA == dominantB; + } + + private static int GetDominantBoneIndex(in BoneWeight weight) + { + var max = weight.weight0; + var index = weight.index0; + + if (weight.weight1 > max) + { + max = weight.weight1; + index = weight.index1; + } + if (weight.weight2 > max) + { + max = weight.weight2; + index = weight.index2; + } + if (weight.weight3 > max) + { + max = weight.weight3; + index = weight.index3; + } + + return max > 0f ? index : -1; + } + + private static float GetBoneWeightOverlap(in BoneWeight a, in BoneWeight b) + { + float overlap = 0f; + AddSharedWeight(a.index0, a.weight0, b, ref overlap); + AddSharedWeight(a.index1, a.weight1, b, ref overlap); + AddSharedWeight(a.index2, a.weight2, b, ref overlap); + AddSharedWeight(a.index3, a.weight3, b, ref overlap); + return overlap; + } + + private static float GetBoneWeightSum(in BoneWeight weight) + => weight.weight0 + weight.weight1 + weight.weight2 + weight.weight3; + + private static void AddSharedWeight(int index, float weight, in BoneWeight other, ref float overlap) + { + if (weight <= 0f) + { + return; + } + + if (index == other.index0) + { + overlap += MathF.Min(weight, other.weight0); + } + else if (index == other.index1) + { + overlap += MathF.Min(weight, other.weight1); + } + else if (index == other.index2) + { + overlap += MathF.Min(weight, other.weight2); + } + else if (index == other.index3) + { + overlap += MathF.Min(weight, other.weight3); + } + } + + // TODO : Fix this (doesn't seems to work properly + public bool CollapseWillInvert(EdgeCollapse edge) + { + int nodeIndexA = _mesh.PositionToNode[edge.posA]; + int nodeIndexB = _mesh.PositionToNode[edge.posB]; + Vector3 positionA = _mesh.positions[edge.posA]; + Vector3 positionB = _mesh.positions[edge.posB]; + var minAreaRatioSq = _MinTriangleAreaRatio * _MinTriangleAreaRatio; + + int sibling = nodeIndexA; + do + { + int posC = _mesh.nodes[_mesh.nodes[sibling].relative].position; + int posD = _mesh.nodes[_mesh.nodes[_mesh.nodes[sibling].relative].relative].position; + + if (posC == edge.posB || posD == edge.posB) + { + continue; + } + + Vector3F edgeAC = _mesh.positions[posC] - positionA; + Vector3F edgeAD = _mesh.positions[posD] - positionA; + Vector3F edgeCD = _mesh.positions[posD] - _mesh.positions[posC]; + var normalBefore = Vector3F.Cross(edgeAC, edgeAD); + + Vector3F edgeRC = _mesh.positions[posC] - edge.result; + Vector3F edgeRD = _mesh.positions[posD] - edge.result; + var normalAfter = Vector3F.Cross(edgeRC, edgeRD); + if (ShouldRejectBodyTriangle(edge.result, _mesh.positions[posC], _mesh.positions[posD])) + { + _rejectedBodyCollision++; + return false; + } + if (IsDegenerateTriangle(edgeAC, edgeAD, edgeCD, normalBefore) + || IsDegenerateTriangle(edgeRC, edgeRD, edgeCD, normalAfter)) + { + _rejectedDegenerate++; + _rejectedInversion++; + return false; + } + if (normalAfter.SqrMagnitude < normalBefore.SqrMagnitude * minAreaRatioSq) + { + _rejectedArea++; + _rejectedInversion++; + return false; + } + + var dot = Vector3F.Dot(normalBefore, normalAfter); + if (dot <= 0f) + { + _rejectedFlip++; + _rejectedInversion++; + return false; + } + } while ((sibling = _mesh.nodes[sibling].sibling) != nodeIndexA); + + sibling = nodeIndexB; + do + { + int posC = _mesh.nodes[_mesh.nodes[sibling].relative].position; + int posD = _mesh.nodes[_mesh.nodes[_mesh.nodes[sibling].relative].relative].position; + + if (posC == edge.posA || posD == edge.posA) + { + continue; + } + + Vector3F edgeAC = _mesh.positions[posC] - positionB; + Vector3F edgeAD = _mesh.positions[posD] - positionB; + Vector3F edgeCD = _mesh.positions[posD] - _mesh.positions[posC]; + var normalBefore = Vector3F.Cross(edgeAC, edgeAD); + + Vector3F edgeRC = _mesh.positions[posC] - edge.result; + Vector3F edgeRD = _mesh.positions[posD] - edge.result; + var normalAfter = Vector3F.Cross(edgeRC, edgeRD); + if (ShouldRejectBodyTriangle(edge.result, _mesh.positions[posC], _mesh.positions[posD])) + { + _rejectedBodyCollision++; + return false; + } + if (IsDegenerateTriangle(edgeAC, edgeAD, edgeCD, normalBefore) + || IsDegenerateTriangle(edgeRC, edgeRD, edgeCD, normalAfter)) + { + _rejectedDegenerate++; + _rejectedInversion++; + return false; + } + if (normalAfter.SqrMagnitude < normalBefore.SqrMagnitude * minAreaRatioSq) + { + _rejectedArea++; + _rejectedInversion++; + return false; + } + + var dot = Vector3F.Dot(normalBefore, normalAfter); + if (dot <= 0f) + { + _rejectedFlip++; + _rejectedInversion++; + return false; + } + } while ((sibling = _mesh.nodes[sibling].sibling) != nodeIndexB); + + return true; + } + + /// + /// A |\ + /// | \ + /// |__\ X + /// | / + /// | / + /// B |/ + /// + /// + /// + /// + /// + private double ComputeLineicError(in Vector3 A, in Vector3 B, in Vector3 X) + { + return Vector3.DistancePointLine(X, A, B); + } + + private double ComputeVertexError(in SymmetricMatrix q, double x, double y, double z) + { + return q.m0 * x * x + 2 * q.m1 * x * y + 2 * q.m2 * x * z + 2 * q.m3 * x + + q.m4 * y * y + 2 * q.m5 * y * z + 2 * q.m6 * y + + q.m7 * z * z + 2 * q.m8 * z + + q.m9; + } + + private void InterpolateAttributes(EdgeCollapse pair) + { + int posA = pair.posA; + int posB = pair.posB; + + int nodeIndexA = _mesh.PositionToNode[posA]; + int nodeIndexB = _mesh.PositionToNode[posB]; + + Vector3 positionA = _mesh.positions[posA]; + Vector3 positionB = _mesh.positions[posB]; + + HashSet procAttributes = new HashSet(); + + Vector3 positionN = pair.result; + double AN = Vector3.Magnitude(positionA - positionN); + double BN = Vector3.Magnitude(positionB - positionN); + double ratio = MathUtils.DivideSafe(AN, AN + BN); + + /* // Other way (same results I think) + double ratio = 0; + double dot = Vector3.Dot(pair.result - positionA, positionB - positionA); + if (dot > 0) + ratio = Math.Sqrt(dot); + ratio /= (positionB - positionA).Length; + */ + + // TODO : Probleme d'interpolation + + + int siblingOfA = nodeIndexA; + do // Iterator over faces around A + { + int relativeOfA = siblingOfA; + do // Circulate around face + { + if (_mesh.nodes[relativeOfA].position == posB) + { + if (!procAttributes.Add(_mesh.nodes[siblingOfA].attribute)) + continue; + + if (!procAttributes.Add(_mesh.nodes[relativeOfA].attribute)) + continue; + + if (_mesh.attributes != null && _mesh.attributeDefinitions.Length > 0) + { + IMetaAttribute attributeA = _mesh.attributes[_mesh.nodes[siblingOfA].attribute]; + IMetaAttribute attributeB = _mesh.attributes[_mesh.nodes[relativeOfA].attribute]; + + for (int i = 0; i < _mesh.attributeDefinitions.Length; i++) + { + if (_mesh.attributeDefinitions[i].type == AttributeType.Normals) + { + Vector3F normalA = attributeA.Get(i); + Vector3F normalB = attributeB.Get(i); + + float dot = Vector3F.Dot(normalA, normalB); + + if (dot < _mergeNormalsThresholdCos) + { + continue; + } + } + + _mesh.attributes.Interpolate(i, _mesh.nodes[siblingOfA].attribute, _mesh.nodes[relativeOfA].attribute, ratio); + } + } + } + } while ((relativeOfA = _mesh.nodes[relativeOfA].relative) != siblingOfA); + + } while ((siblingOfA = _mesh.nodes[siblingOfA].sibling) != nodeIndexA); + + + /* + int attrIndex = _mesh.nodes[nodeIndexA].attribute; + + int siblingOfA = nodeIndexA; + do + { + _mesh.nodes[siblingOfA].attribute = attrIndex; + } while ((siblingOfA = _mesh.nodes[siblingOfA].sibling) != nodeIndexA); + + int siblingOfB = nodeIndexB; + do + { + _mesh.nodes[siblingOfB].attribute = attrIndex; + } while ((siblingOfB = _mesh.nodes[siblingOfB].sibling) != nodeIndexB); + */ + } + + private readonly Dictionary _uniqueAttributes = new Dictionary(); + + private void MergeAttributes(int nodeIndex) + { + if (_mesh.attributeDefinitions.Length == 0) + return; + + _uniqueAttributes.Clear(); + + int sibling = nodeIndex; + do + { + _uniqueAttributes.TryAdd(_mesh.attributes[_mesh.nodes[sibling].attribute], _mesh.nodes[sibling].attribute); + } while ((sibling = _mesh.nodes[sibling].sibling) != nodeIndex); + + sibling = nodeIndex; + do + { + _mesh.nodes[sibling].attribute = _uniqueAttributes[_mesh.attributes[_mesh.nodes[sibling].attribute]]; + } while ((sibling = _mesh.nodes[sibling].sibling) != nodeIndex); + } + + private readonly HashSet _edgeToRefresh = new HashSet(); + + private void CollapseEdge(EdgeCollapse pair) + { + _collapsedEdges++; + int nodeIndexA = _mesh.PositionToNode[pair.posA]; + int nodeIndexB = _mesh.PositionToNode[pair.posB]; + + int posA = pair.posA; + int posB = pair.posB; + + // Remove all edges around A + int sibling = nodeIndexA; + //for (relative = sibling; relative != sibling; relative = _mesh.nodes[relative].relative) + //for (sibling = nodeIndexA; sibling != nodeIndexA; sibling = _mesh.nodes[sibling].sibling) + do + { + for (int relative = sibling; (relative = _mesh.nodes[relative].relative) != sibling;) + { + int posC = _mesh.nodes[relative].position; + EdgeCollapse pairAC = new EdgeCollapse(posA, posC); + // Todo : Optimization by only removing first pair (first edge) + if (_pairs.Remove(pairAC)) + { + _mins.Remove(pairAC); + } + } + } while ((sibling = _mesh.nodes[sibling].sibling) != nodeIndexA); + + // Remove all edges around B + sibling = nodeIndexB; + do + { + for (int relative = sibling; (relative = _mesh.nodes[relative].relative) != sibling;) + { + int posC = _mesh.nodes[relative].position; + EdgeCollapse pairBC = new EdgeCollapse(posB, posC); + if (_pairs.Remove(pairBC)) + { + _mins.Remove(pairBC); + } + } + } while ((sibling = _mesh.nodes[sibling].sibling) != nodeIndexB); + + // Interpolates attributes + InterpolateAttributes(pair); + + // Collapse edge + int validNode = _mesh.CollapseEdge(nodeIndexA, nodeIndexB); + + // A disconnected triangle has been collapsed, there are no edges to register + if (validNode < 0) + { + return; + } + + posA = _mesh.nodes[validNode].position; + + _mesh.positions[posA] = pair.result; + + MergeAttributes(validNode); + + CalculateQuadric(posA); + + _edgeToRefresh.Clear(); + + sibling = validNode; + do + { + for (int relative = sibling; (relative = _mesh.nodes[relative].relative) != sibling;) + { + int posC = _mesh.nodes[relative].position; + _edgeToRefresh.Add(new EdgeCollapse(posA, posC)); + + if (UpdateFarNeighbors) + { + int sibling2 = relative; + while ((sibling2 = _mesh.nodes[sibling2].sibling) != relative) + { + int relative2 = sibling2; + while ((relative2 = _mesh.nodes[relative2].relative) != sibling2) + { + int posD = _mesh.nodes[relative2].position; + if (posD != posC) + { + _edgeToRefresh.Add(new EdgeCollapse(posC, posD)); + } + } + } + } + } + } while ((sibling = _mesh.nodes[sibling].sibling) != validNode); + + foreach (EdgeCollapse edge in _edgeToRefresh) + { + CalculateQuadric(edge.posB); + edge.SetWeight(-1); + _pairs.Remove(edge); + _pairs.Add(edge); + } + + foreach (EdgeCollapse edge in _edgeToRefresh) + { + CalculateError(edge); + _mins.Remove(edge); + if (UpdateMinsOnCollapse) + { + _mins.AddMin(edge); + } + } + } + + private void ResetStats() + { + _evaluatedEdges = 0; + _collapsedEdges = 0; + _rejectedBoneWeights = 0; + _rejectedTopology = 0; + _rejectedInversion = 0; + _rejectedDegenerate = 0; + _rejectedArea = 0; + _rejectedFlip = 0; + _rejectedBodyCollision = 0; + } + + private bool IsPointNearBody(in Vector3 point) + { + if (_bodyDistanceSqEvaluator == null || _bodyDistanceThresholdSq <= 0f) + { + return false; + } + + var sq = _bodyDistanceSqEvaluator(point); + return !float.IsNaN(sq) && sq <= _bodyDistanceThresholdSq; + } + + private bool IsPointNearBody(in Vector3 point, float thresholdSq) + { + if (_bodyDistanceSqEvaluator == null || thresholdSq <= 0f) + { + return false; + } + + var sq = _bodyDistanceSqEvaluator(point); + return !float.IsNaN(sq) && sq <= thresholdSq; + } + + private bool ShouldRejectBodyTriangle(in Vector3 a, in Vector3 b, in Vector3 c) + { + if (_bodyDistanceSqEvaluator == null || _bodyDistanceThresholdSq <= 0f) + { + return false; + } + + var centroid = (a + b + c) / 3d; + if (!CollapseToEndpointsOnly) + { + return IsPointNearBody(centroid); + } + + var penetrationFactor = MathF.Max(0f, BodyCollisionPenetrationFactor); + var penetrationThresholdSq = _bodyDistanceThresholdSq * penetrationFactor * penetrationFactor; + if (IsPointNearBody(centroid, penetrationThresholdSq)) + { + return true; + } + + var ab = (a + b) * 0.5; + var bc = (b + c) * 0.5; + var ca = (c + a) * 0.5; + return IsPointNearBody(ab, penetrationThresholdSq) + || IsPointNearBody(bc, penetrationThresholdSq) + || IsPointNearBody(ca, penetrationThresholdSq); + } + + private bool IsProtectedVertex(int pos) + { + if (_protectedVertices == null) + { + return false; + } + + return (uint)pos < (uint)_protectedVertices.Length && _protectedVertices[pos]; + } + + private static bool IsDegenerateTriangle(in Vector3F edge0, in Vector3F edge1, in Vector3F edge2, in Vector3F normal) + { + var maxEdgeSq = MathF.Max(edge0.SqrMagnitude, MathF.Max(edge1.SqrMagnitude, edge2.SqrMagnitude)); + if (maxEdgeSq <= 0f) + { + return true; + } + + var minNormalSq = (float)(_DeterminantEpsilon * _DeterminantEpsilon) * maxEdgeSq * maxEdgeSq; + return normal.SqrMagnitude <= minNormalSq; + } + } + + public readonly record struct DecimationStats( + int EvaluatedEdges, + int CollapsedEdges, + int RejectedBoneWeights, + int RejectedTopology, + int RejectedInversion, + int RejectedDegenerate, + int RejectedArea, + int RejectedFlip, + int RejectedBodyCollision); +} diff --git a/LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/EdgeCollapse.cs b/LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/EdgeCollapse.cs new file mode 100644 index 0000000..62cae64 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/EdgeCollapse.cs @@ -0,0 +1,88 @@ +using System; + +namespace Nanomesh +{ + public partial class DecimateModifier + { + public class EdgeCollapse : IComparable, IEquatable + { + public int posA; + public int posB; + public Vector3 result; + public double error; + + private double _weight = -1; + + public ref double Weight => ref _weight; + + public void SetWeight(double weight) + { + _weight = weight; + } + + public EdgeCollapse(int posA, int posB) + { + this.posA = posA; + this.posB = posB; + } + + public override int GetHashCode() + { + unchecked + { + return posA + posB; + } + } + + public override bool Equals(object obj) + { + return Equals((EdgeCollapse)obj); + } + + public bool Equals(EdgeCollapse pc) + { + if (ReferenceEquals(pc, null)) + return false; + + if (ReferenceEquals(this, pc)) + { + return true; + } + else + { + return (posA == pc.posA && posB == pc.posB) || (posA == pc.posB && posB == pc.posA); + } + } + + public int CompareTo(EdgeCollapse other) + { + return error > other.error ? 1 : error < other.error ? -1 : 0; + } + + public static bool operator >(EdgeCollapse x, EdgeCollapse y) + { + return x.error > y.error; + } + + public static bool operator >=(EdgeCollapse x, EdgeCollapse y) + { + return x.error >= y.error; + } + + public static bool operator <(EdgeCollapse x, EdgeCollapse y) + { + return x.error < y.error; + } + + public static bool operator <=(EdgeCollapse x, EdgeCollapse y) + { + return x.error <= y.error; + } + + public override string ToString() + { + return $""; + } + } + } +} diff --git a/LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/EdgeComparer.cs b/LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/EdgeComparer.cs new file mode 100644 index 0000000..4fb45e6 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/EdgeComparer.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace Nanomesh +{ + public partial class DecimateModifier + { + private class EdgeComparer : IComparer + { + public int Compare(EdgeCollapse x, EdgeCollapse y) + { + return x.CompareTo(y); + } + } + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/SceneDecimator.cs b/LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/SceneDecimator.cs new file mode 100644 index 0000000..c21cbc2 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/SceneDecimator.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Nanomesh +{ + public class SceneDecimator + { + private class ModifierAndOccurrences + { + public int occurrences = 1; + public DecimateModifier modifier = new DecimateModifier(); + } + + private Dictionary _modifiers; + + public void Initialize(IEnumerable meshes) + { + _modifiers = new Dictionary(); + + foreach (ConnectedMesh mesh in meshes) + { + ModifierAndOccurrences modifier; + if (_modifiers.ContainsKey(mesh)) + { + modifier = _modifiers[mesh]; + modifier.occurrences++; + } + else + { + _modifiers.Add(mesh, modifier = new ModifierAndOccurrences()); + //System.Console.WriteLine($"Faces:{mesh.FaceCount}"); + modifier.modifier.Initialize(mesh); + } + + _faceCount += mesh.FaceCount; + } + + _initalFaceCount = _faceCount; + } + + private int _faceCount; + private int _initalFaceCount; + + public void DecimateToRatio(float targetTriangleRatio) + { + targetTriangleRatio = MathF.Clamp(targetTriangleRatio, 0f, 1f); + DecimateToPolycount((int)MathF.Round(targetTriangleRatio * _initalFaceCount)); + } + + public void DecimatePolycount(int polycount) + { + DecimateToPolycount((int)MathF.Round(_initalFaceCount - polycount)); + } + + public void DecimateToPolycount(int targetTriangleCount) + { + //System.Console.WriteLine($"Faces:{_faceCount} Target:{targetTriangleCount}"); + while (_faceCount > targetTriangleCount) + { + KeyValuePair pair = _modifiers.OrderBy(x => x.Value.modifier.GetMinimumError()).First(); + + int facesBefore = pair.Key.FaceCount; + pair.Value.modifier.Iterate(); + + if (facesBefore == pair.Key.FaceCount) + break; // Exit ! + + _faceCount -= (facesBefore - pair.Key.FaceCount) * pair.Value.occurrences; + } + } + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Algo/NormalsCreator.cs b/LightlessSync/ThirdParty/Nanomesh/Algo/NormalsCreator.cs new file mode 100644 index 0000000..5c37321 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Algo/NormalsCreator.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Nanomesh +{ + public class NormalsModifier + { + public struct PosAndAttribute : IEquatable + { + public int position; + public Attribute attribute; + + public override int GetHashCode() + { + return position.GetHashCode() ^ (attribute.GetHashCode() << 2); + } + + public bool Equals(PosAndAttribute other) + { + return position == other.position && attribute.Equals(other.attribute); + } + } + + public void Run(ConnectedMesh mesh, float smoothingAngle) + { + float cosineThreshold = MathF.Cos(smoothingAngle * MathF.PI / 180f); + + int[] positionToNode = mesh.GetPositionToNode(); + + Dictionary attributeToIndex = new Dictionary(); + + for (int p = 0; p < positionToNode.Length; p++) + { + int nodeIndex = positionToNode[p]; + if (nodeIndex < 0) + { + continue; + } + + Debug.Assert(!mesh.nodes[nodeIndex].IsRemoved); + + int sibling1 = nodeIndex; + do + { + Vector3F sum = Vector3F.Zero; + + Vector3F normal1 = mesh.GetFaceNormal(sibling1); + + int sibling2 = nodeIndex; + do + { + Vector3F normal2 = mesh.GetFaceNormal(sibling2); + + float dot = Vector3F.Dot(normal1, normal2); + + if (dot >= cosineThreshold) + { + // Area and angle weighting (it gives better results) + sum += mesh.GetFaceArea(sibling2) * mesh.GetAngleRadians(sibling2) * normal2; + } + + } while ((sibling2 = mesh.nodes[sibling2].sibling) != nodeIndex); + + sum = sum.Normalized; + + + } while ((sibling1 = mesh.nodes[sibling1].sibling) != nodeIndex); + } + + // Assign new attributes + + // TODO : Fix + } + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Algo/NormalsFixer.cs b/LightlessSync/ThirdParty/Nanomesh/Algo/NormalsFixer.cs new file mode 100644 index 0000000..5e65476 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Algo/NormalsFixer.cs @@ -0,0 +1,17 @@ +namespace Nanomesh +{ + public class NormalsFixer + { + public void Start(ConnectedMesh mesh) + { + /* + for (int i = 0; i < mesh.attributes.Length; i++) + { + Attribute attribute = mesh.attributes[i]; + attribute.normal = attribute.normal.Normalized; + mesh.attributes[i] = attribute; + } + */ + } + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Algo/Triangulate.cs b/LightlessSync/ThirdParty/Nanomesh/Algo/Triangulate.cs new file mode 100644 index 0000000..8c69394 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Algo/Triangulate.cs @@ -0,0 +1,27 @@ +using System; + +namespace Nanomesh +{ + public class TriangulateModifier + { + public void Run(ConnectedMesh mesh) + { + for (int i = 0; i < mesh.nodes.Length; i++) + { + int edgeCount = 0; + int relative = i; + while ((relative = mesh.nodes[relative].relative) != i) // Circulate around face + { + edgeCount++; + } + + if (edgeCount > 2) + { + throw new Exception("Mesh has polygons of dimension 4 or greater"); + } + } + + // Todo : Implement + } + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Base/BoneWeight.cs b/LightlessSync/ThirdParty/Nanomesh/Base/BoneWeight.cs new file mode 100644 index 0000000..c784572 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Base/BoneWeight.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Nanomesh +{ + public readonly struct BoneWeight : IEquatable, IInterpolable + { + public readonly int index0; + public readonly int index1; + public readonly int index2; + public readonly int index3; + public readonly float weight0; + public readonly float weight1; + public readonly float weight2; + public readonly float weight3; + + public int GetIndex(int i) + { + switch (i) + { + case 0: return index0; + case 1: return index1; + case 2: return index2; + case 3: return index3; + default: return -1; + } + } + + public float GetWeight(int i) + { + switch (i) + { + case 0: return weight0; + case 1: return weight1; + case 2: return weight2; + case 3: return weight3; + default: return -1; + } + } + + public BoneWeight(int index0, int index1, int index2, int index3, float weight0, float weight1, float weight2, float weight3) + { + this.index0 = index0; + this.index1 = index1; + this.index2 = index2; + this.index3 = index3; + this.weight0 = weight0; + this.weight1 = weight1; + this.weight2 = weight2; + this.weight3 = weight3; + } + + public bool Equals(BoneWeight other) + { + return index0 == other.index0 + && index1 == other.index1 + && index2 == other.index2 + && index3 == other.index3 + && weight0 == other.weight0 + && weight1 == other.weight1 + && weight2 == other.weight2 + && weight3 == other.weight3; + } + + public override int GetHashCode() + { + unchecked + { + int hash = 17; + hash = hash * 31 + index0; + hash = hash * 31 + index1; + hash = hash * 31 + index2; + hash = hash * 31 + index3; + hash = hash * 31 + weight0.GetHashCode(); + hash = hash * 31 + weight1.GetHashCode(); + hash = hash * 31 + weight2.GetHashCode(); + hash = hash * 31 + weight3.GetHashCode(); + return hash; + } + } + + public unsafe BoneWeight Interpolate(BoneWeight other, double ratio) + { + BoneWeight boneWeightA = this; + BoneWeight boneWeightB = other; + + Dictionary newBoneWeight = new Dictionary(); + + // Map weights and indices + for (int i = 0; i < 4; i++) + { + newBoneWeight.TryAdd(boneWeightA.GetIndex(i), 0); + newBoneWeight.TryAdd(boneWeightB.GetIndex(i), 0); + newBoneWeight[boneWeightA.GetIndex(i)] += (float)((1 - ratio) * boneWeightA.GetWeight(i)); + newBoneWeight[boneWeightB.GetIndex(i)] += (float)(ratio * boneWeightB.GetWeight(i)); + } + + int* newIndices = stackalloc int[4]; + float* newWeights = stackalloc float[4]; + + // Order from biggest to smallest weight, and drop bones above 4th + float totalWeight = 0; + int k = 0; + foreach (KeyValuePair boneWeightN in newBoneWeight.OrderByDescending(x => x.Value)) + { + newIndices[k] = boneWeightN.Key; + newWeights[k] = boneWeightN.Value; + totalWeight += boneWeightN.Value; + if (k == 3) + break; + k++; + } + + var sumA = boneWeightA.weight0 + boneWeightA.weight1 + boneWeightA.weight2 + boneWeightA.weight3; + var sumB = boneWeightB.weight0 + boneWeightB.weight1 + boneWeightB.weight2 + boneWeightB.weight3; + var targetSum = (float)((1d - ratio) * sumA + ratio * sumB); + + // Normalize and re-scale to preserve original weight sum. + if (totalWeight > 0f) + { + var scale = targetSum / totalWeight; + for (int j = 0; j < 4; j++) + { + newWeights[j] *= scale; + } + } + + return new BoneWeight( + newIndices[0], newIndices[1], newIndices[2], newIndices[3], + newWeights[0], newWeights[1], newWeights[2], newWeights[3]); + + //return new BoneWeight( + // ratio < 0.5f ? index0 : other.index0, + // ratio < 0.5f ? index1 : other.index1, + // ratio < 0.5f ? index2 : other.index2, + // ratio < 0.5f ? index3 : other.index3, + // (float)(ratio * weight0 + (1 - ratio) * other.weight0), + // (float)(ratio * weight1 + (1 - ratio) * other.weight1), + // (float)(ratio * weight2 + (1 - ratio) * other.weight2), + // (float)(ratio * weight3 + (1 - ratio) * other.weight3)); + } + } +} diff --git a/LightlessSync/ThirdParty/Nanomesh/Base/Color32.cs b/LightlessSync/ThirdParty/Nanomesh/Base/Color32.cs new file mode 100644 index 0000000..49a4216 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Base/Color32.cs @@ -0,0 +1,110 @@ +using System; +using System.Runtime.InteropServices; + +namespace Nanomesh +{ + [StructLayout(LayoutKind.Explicit)] + public readonly struct Color32 : IEquatable, IInterpolable + { + [FieldOffset(0)] + internal readonly int rgba; + + [FieldOffset(0)] + public readonly byte r; + + [FieldOffset(1)] + public readonly byte g; + + [FieldOffset(2)] + public readonly byte b; + + [FieldOffset(3)] + public readonly byte a; + + public Color32(byte r, byte g, byte b, byte a) + { + rgba = 0; + this.r = r; + this.g = g; + this.b = b; + this.a = a; + } + + public Color32(float r, float g, float b, float a) + { + rgba = 0; + this.r = (byte)MathF.Round(r); + this.g = (byte)MathF.Round(g); + this.b = (byte)MathF.Round(b); + this.a = (byte)MathF.Round(a); + } + + public Color32(double r, double g, double b, double a) + { + rgba = 0; + this.r = (byte)Math.Round(r); + this.g = (byte)Math.Round(g); + this.b = (byte)Math.Round(b); + this.a = (byte)Math.Round(a); + } + + public bool Equals(Color32 other) + { + return other.rgba == rgba; + } + + public Color32 Interpolate(Color32 other, double ratio) + { + return ratio * this + (1 - ratio) * other; + } + + /// + /// Adds two colors. + /// + /// + public static Color32 operator +(Color32 a, Color32 b) { return new Color32(a.r + b.r, a.g + b.g, a.b + b.b, a.a + b.a); } + + /// + /// Subtracts one color from another. + /// + /// + public static Color32 operator -(Color32 a, Color32 b) { return new Color32(1f * a.r - b.r, a.g - b.g, a.b - b.b, a.a - b.a); } + + /// + /// Multiplies one color by another. + /// + /// + public static Color32 operator *(Color32 a, Color32 b) { return new Color32(1f * a.r * b.r, 1f * a.g * b.g, 1f * a.b * b.b, 1f * a.a * b.a); } + + /// + /// Divides one color over another. + /// + /// + public static Color32 operator /(Color32 a, Color32 b) { return new Color32(1f * a.r / b.r, 1f * a.g / b.g, 1f * a.b / b.b, 1f * a.a / b.a); } + + + /// + /// Multiplies a color by a number. + /// + /// + /// + /// + public static Color32 operator *(Color32 a, float d) { return new Color32(d * a.r, d * a.g, d * a.b, d * a.a); } + + public static Color32 operator *(Color32 a, double d) { return new Color32(d * a.r, d * a.g, d * a.b, d * a.a); } + + /// + /// Multiplies a color by a number. + /// + /// + public static Color32 operator *(float d, Color32 a) { return new Color32(d * a.r, d * a.g, d * a.b, d * a.a); } + + public static Color32 operator *(double d, Color32 a) { return new Color32(d * a.r, d * a.g, d * a.b, d * a.a); } + + /// + /// Divides a color by a number. + /// + /// + public static Color32 operator /(Color32 a, float d) { return new Color32(1f * a.r / d, 1f * a.g / d, 1f * a.b / d, 1f * a.a / d); } + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Base/FfxivVertexAttribute.cs b/LightlessSync/ThirdParty/Nanomesh/Base/FfxivVertexAttribute.cs new file mode 100644 index 0000000..f0bacb0 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Base/FfxivVertexAttribute.cs @@ -0,0 +1,347 @@ +using System; +using System.Runtime.InteropServices; + +namespace Nanomesh +{ + [Flags] + public enum FfxivAttributeFlags : uint + { + None = 0, + Normal = 1u << 0, + Tangent1 = 1u << 1, + Tangent2 = 1u << 2, + Color = 1u << 3, + BoneWeights = 1u << 4, + PositionW = 1u << 5, + NormalW = 1u << 6, + Uv0 = 1u << 7, + Uv1 = 1u << 8, + Uv2 = 1u << 9, + Uv3 = 1u << 10, + } + + [StructLayout(LayoutKind.Sequential)] + public readonly struct FfxivVertexAttribute : IEquatable, IInterpolable + { + public readonly Vector3F normal; + public readonly Vector4F tangent1; + public readonly Vector4F tangent2; + public readonly Vector2F uv0; + public readonly Vector2F uv1; + public readonly Vector2F uv2; + public readonly Vector2F uv3; + public readonly Vector4F color; + public readonly BoneWeight boneWeight; + public readonly float positionW; + public readonly float normalW; + public readonly FfxivAttributeFlags flags; + + public FfxivVertexAttribute( + FfxivAttributeFlags flags, + Vector3F normal, + Vector4F tangent1, + Vector4F tangent2, + Vector2F uv0, + Vector2F uv1, + Vector2F uv2, + Vector2F uv3, + Vector4F color, + BoneWeight boneWeight, + float positionW, + float normalW) + { + this.flags = flags; + this.normal = normal; + this.tangent1 = tangent1; + this.tangent2 = tangent2; + this.uv0 = uv0; + this.uv1 = uv1; + this.uv2 = uv2; + this.uv3 = uv3; + this.color = color; + this.boneWeight = boneWeight; + this.positionW = positionW; + this.normalW = normalW; + } + + public FfxivVertexAttribute Interpolate(FfxivVertexAttribute other, double ratio) + { + var t = (float)ratio; + var inv = 1f - t; + var combinedFlags = flags | other.flags; + + var normal = (combinedFlags & FfxivAttributeFlags.Normal) != 0 + ? NormalizeVector3(new Vector3F( + (this.normal.x * inv) + (other.normal.x * t), + (this.normal.y * inv) + (other.normal.y * t), + (this.normal.z * inv) + (other.normal.z * t))) + : default; + + var tangent1 = (combinedFlags & FfxivAttributeFlags.Tangent1) != 0 + ? BlendTangent(this.tangent1, other.tangent1, t) + : default; + + var tangent2 = (combinedFlags & FfxivAttributeFlags.Tangent2) != 0 + ? BlendTangent(this.tangent2, other.tangent2, t) + : default; + + var uv0 = (combinedFlags & FfxivAttributeFlags.Uv0) != 0 + ? Vector2F.LerpUnclamped(this.uv0, other.uv0, t) + : default; + + var uv1 = (combinedFlags & FfxivAttributeFlags.Uv1) != 0 + ? Vector2F.LerpUnclamped(this.uv1, other.uv1, t) + : default; + + var uv2 = (combinedFlags & FfxivAttributeFlags.Uv2) != 0 + ? Vector2F.LerpUnclamped(this.uv2, other.uv2, t) + : default; + + var uv3 = (combinedFlags & FfxivAttributeFlags.Uv3) != 0 + ? Vector2F.LerpUnclamped(this.uv3, other.uv3, t) + : default; + + var color = (combinedFlags & FfxivAttributeFlags.Color) != 0 + ? new Vector4F( + (this.color.x * inv) + (other.color.x * t), + (this.color.y * inv) + (other.color.y * t), + (this.color.z * inv) + (other.color.z * t), + (this.color.w * inv) + (other.color.w * t)) + : default; + + var boneWeight = (combinedFlags & FfxivAttributeFlags.BoneWeights) != 0 + ? BlendBoneWeights(this.boneWeight, other.boneWeight, t) + : default; + + var positionW = (combinedFlags & FfxivAttributeFlags.PositionW) != 0 + ? (this.positionW * inv) + (other.positionW * t) + : 0f; + + var normalW = (combinedFlags & FfxivAttributeFlags.NormalW) != 0 + ? (this.normalW * inv) + (other.normalW * t) + : 0f; + + return new FfxivVertexAttribute( + combinedFlags, + normal, + tangent1, + tangent2, + uv0, + uv1, + uv2, + uv3, + color, + boneWeight, + positionW, + normalW); + } + + public bool Equals(FfxivVertexAttribute other) + { + if (flags != other.flags) + { + return false; + } + + if ((flags & FfxivAttributeFlags.Normal) != 0 && !normal.Equals(other.normal)) + { + return false; + } + + if ((flags & FfxivAttributeFlags.Tangent1) != 0 && !tangent1.Equals(other.tangent1)) + { + return false; + } + + if ((flags & FfxivAttributeFlags.Tangent2) != 0 && !tangent2.Equals(other.tangent2)) + { + return false; + } + + if ((flags & FfxivAttributeFlags.Uv0) != 0 && !uv0.Equals(other.uv0)) + { + return false; + } + + if ((flags & FfxivAttributeFlags.Uv1) != 0 && !uv1.Equals(other.uv1)) + { + return false; + } + + if ((flags & FfxivAttributeFlags.Uv2) != 0 && !uv2.Equals(other.uv2)) + { + return false; + } + + if ((flags & FfxivAttributeFlags.Uv3) != 0 && !uv3.Equals(other.uv3)) + { + return false; + } + + if ((flags & FfxivAttributeFlags.Color) != 0 && !color.Equals(other.color)) + { + return false; + } + + if ((flags & FfxivAttributeFlags.BoneWeights) != 0 && !boneWeight.Equals(other.boneWeight)) + { + return false; + } + + if ((flags & FfxivAttributeFlags.PositionW) != 0 && positionW != other.positionW) + { + return false; + } + + if ((flags & FfxivAttributeFlags.NormalW) != 0 && normalW != other.normalW) + { + return false; + } + + return true; + } + + public override bool Equals(object? obj) + => obj is FfxivVertexAttribute other && Equals(other); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(normal); + hash.Add(tangent1); + hash.Add(tangent2); + hash.Add(uv0); + hash.Add(uv1); + hash.Add(uv2); + hash.Add(uv3); + hash.Add(color); + hash.Add(boneWeight); + hash.Add(positionW); + hash.Add(normalW); + hash.Add(flags); + return hash.ToHashCode(); + } + + private static Vector3F NormalizeVector3(in Vector3F value) + { + var length = Vector3F.Magnitude(value); + return length > 0f ? value / length : value; + } + + private static Vector4F BlendTangent(in Vector4F a, in Vector4F b, float t) + { + var inv = 1f - t; + var blended = new Vector3F( + (a.x * inv) + (b.x * t), + (a.y * inv) + (b.y * t), + (a.z * inv) + (b.z * t)); + blended = NormalizeVector3(blended); + + var w = t >= 0.5f ? b.w : a.w; + if (w != 0f) + { + w = w >= 0f ? 1f : -1f; + } + + return new Vector4F(blended.x, blended.y, blended.z, w); + } + + private static BoneWeight BlendBoneWeights(in BoneWeight a, in BoneWeight b, float ratio) + { + Span indices = stackalloc int[8]; + Span weights = stackalloc float[8]; + var count = 0; + + static void AddWeight(Span indices, Span weights, ref int count, int index, float weight) + { + if (weight <= 0f) + { + return; + } + + for (var i = 0; i < count; i++) + { + if (indices[i] == index) + { + weights[i] += weight; + return; + } + } + + if (count < indices.Length) + { + indices[count] = index; + weights[count] = weight; + count++; + } + } + + var inv = 1f - ratio; + var sumA = a.weight0 + a.weight1 + a.weight2 + a.weight3; + var sumB = b.weight0 + b.weight1 + b.weight2 + b.weight3; + var targetSum = (sumA * inv) + (sumB * ratio); + AddWeight(indices, weights, ref count, a.index0, a.weight0 * inv); + AddWeight(indices, weights, ref count, a.index1, a.weight1 * inv); + AddWeight(indices, weights, ref count, a.index2, a.weight2 * inv); + AddWeight(indices, weights, ref count, a.index3, a.weight3 * inv); + AddWeight(indices, weights, ref count, b.index0, b.weight0 * ratio); + AddWeight(indices, weights, ref count, b.index1, b.weight1 * ratio); + AddWeight(indices, weights, ref count, b.index2, b.weight2 * ratio); + AddWeight(indices, weights, ref count, b.index3, b.weight3 * ratio); + + if (count == 0) + { + return a; + } + + Span topIndices = stackalloc int[4]; + Span topWeights = stackalloc float[4]; + for (var i = 0; i < 4; i++) + { + topIndices[i] = -1; + topWeights[i] = 0f; + } + + for (var i = 0; i < count; i++) + { + var weight = weights[i]; + var index = indices[i]; + for (var slot = 0; slot < 4; slot++) + { + if (weight > topWeights[slot]) + { + for (var shift = 3; shift > slot; shift--) + { + topWeights[shift] = topWeights[shift - 1]; + topIndices[shift] = topIndices[shift - 1]; + } + + topWeights[slot] = weight; + topIndices[slot] = index; + break; + } + } + } + + var sum = topWeights[0] + topWeights[1] + topWeights[2] + topWeights[3]; + if (sum > 0f) + { + var scale = targetSum > 0f ? targetSum / sum : 0f; + for (var i = 0; i < 4; i++) + { + topWeights[i] *= scale; + } + } + + return new BoneWeight( + topIndices[0] < 0 ? 0 : topIndices[0], + topIndices[1] < 0 ? 0 : topIndices[1], + topIndices[2] < 0 ? 0 : topIndices[2], + topIndices[3] < 0 ? 0 : topIndices[3], + topWeights[0], + topWeights[1], + topWeights[2], + topWeights[3]); + } + } +} diff --git a/LightlessSync/ThirdParty/Nanomesh/Base/IInterpolable.cs b/LightlessSync/ThirdParty/Nanomesh/Base/IInterpolable.cs new file mode 100644 index 0000000..3118194 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Base/IInterpolable.cs @@ -0,0 +1,7 @@ +namespace Nanomesh +{ + public interface IInterpolable + { + T Interpolate(T other, double ratio); + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Base/MathF.cs b/LightlessSync/ThirdParty/Nanomesh/Base/MathF.cs new file mode 100644 index 0000000..c1aef5e --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Base/MathF.cs @@ -0,0 +1,356 @@ +using System; + +namespace Nanomesh +{ + public static partial class MathF + { + // Returns the sine of angle /f/ in radians. + public static float Sin(float f) { return (float)Math.Sin(f); } + + // Returns the cosine of angle /f/ in radians. + public static float Cos(float f) { return (float)Math.Cos(f); } + + // Returns the tangent of angle /f/ in radians. + public static float Tan(float f) { return (float)Math.Tan(f); } + + // Returns the arc-sine of /f/ - the angle in radians whose sine is /f/. + public static float Asin(float f) { return (float)Math.Asin(f); } + + // Returns the arc-cosine of /f/ - the angle in radians whose cosine is /f/. + public static float Acos(float f) { return (float)Math.Acos(f); } + + // Returns the arc-tangent of /f/ - the angle in radians whose tangent is /f/. + public static float Atan(float f) { return (float)Math.Atan(f); } + + // Returns the angle in radians whose ::ref::Tan is @@y/x@@. + public static float Atan2(float y, float x) { return (float)Math.Atan2(y, x); } + + // Returns square root of /f/. + public static float Sqrt(float f) { return (float)Math.Sqrt(f); } + + // Returns the absolute value of /f/. + public static float Abs(float f) { return (float)Math.Abs(f); } + + // Returns the absolute value of /value/. + public static int Abs(int value) { return Math.Abs(value); } + + /// *listonly* + public static float Min(float a, float b) { return a < b ? a : b; } + // Returns the smallest of two or more values. + public static float Min(params float[] values) + { + int len = values.Length; + if (len == 0) + { + return 0; + } + + float m = values[0]; + for (int i = 1; i < len; i++) + { + if (values[i] < m) + { + m = values[i]; + } + } + return m; + } + + /// *listonly* + public static int Min(int a, int b) { return a < b ? a : b; } + // Returns the smallest of two or more values. + public static int Min(params int[] values) + { + int len = values.Length; + if (len == 0) + { + return 0; + } + + int m = values[0]; + for (int i = 1; i < len; i++) + { + if (values[i] < m) + { + m = values[i]; + } + } + return m; + } + + /// *listonly* + public static float Max(float a, float b) { return a > b ? a : b; } + // Returns largest of two or more values. + public static float Max(params float[] values) + { + int len = values.Length; + if (len == 0) + { + return 0; + } + + float m = values[0]; + for (int i = 1; i < len; i++) + { + if (values[i] > m) + { + m = values[i]; + } + } + return m; + } + + /// *listonly* + public static int Max(int a, int b) { return a > b ? a : b; } + // Returns the largest of two or more values. + public static int Max(params int[] values) + { + int len = values.Length; + if (len == 0) + { + return 0; + } + + int m = values[0]; + for (int i = 1; i < len; i++) + { + if (values[i] > m) + { + m = values[i]; + } + } + return m; + } + + // Returns /f/ raised to power /p/. + public static float Pow(float f, float p) { return (float)Math.Pow(f, p); } + + // Returns e raised to the specified power. + public static float Exp(float power) { return (float)Math.Exp(power); } + + // Returns the logarithm of a specified number in a specified base. + public static float Log(float f, float p) { return (float)Math.Log(f, p); } + + // Returns the natural (base e) logarithm of a specified number. + public static float Log(float f) { return (float)Math.Log(f); } + + // Returns the base 10 logarithm of a specified number. + public static float Log10(float f) { return (float)Math.Log10(f); } + + // Returns the smallest integer greater to or equal to /f/. + public static float Ceil(float f) { return (float)Math.Ceiling(f); } + + // Returns the largest integer smaller to or equal to /f/. + public static float Floor(float f) { return (float)Math.Floor(f); } + + // Returns /f/ rounded to the nearest integer. + public static float Round(float f) { return (float)Math.Round(f); } + + // Returns the smallest integer greater to or equal to /f/. + public static int CeilToInt(float f) { return (int)Math.Ceiling(f); } + + // Returns the largest integer smaller to or equal to /f/. + public static int FloorToInt(float f) { return (int)Math.Floor(f); } + + // Returns /f/ rounded to the nearest integer. + public static int RoundToInt(float f) { return (int)Math.Round(f); } + + // Returns the sign of /f/. + public static float Sign(float f) { return f >= 0F ? 1F : -1F; } + + // The infamous ''3.14159265358979...'' value (RO). + public const float PI = (float)Math.PI; + + // A representation of positive infinity (RO). + public const float Infinity = float.PositiveInfinity; + + // A representation of negative infinity (RO). + public const float NegativeInfinity = float.NegativeInfinity; + + // Degrees-to-radians conversion constant (RO). + public const float Deg2Rad = PI * 2F / 360F; + + // Radians-to-degrees conversion constant (RO). + public const float Rad2Deg = 1F / Deg2Rad; + + // Clamps a value between a minimum float and maximum float value. + public static double Clamp(double value, double min, double max) + { + if (value < min) + { + value = min; + } + else if (value > max) + { + value = max; + } + + return value; + } + + // Clamps a value between a minimum float and maximum float value. + public static float Clamp(float value, float min, float max) + { + if (value < min) + { + value = min; + } + else if (value > max) + { + value = max; + } + + return value; + } + + // Clamps value between min and max and returns value. + // Set the position of the transform to be that of the time + // but never less than 1 or more than 3 + // + public static int Clamp(int value, int min, int max) + { + if (value < min) + { + value = min; + } + else if (value > max) + { + value = max; + } + + return value; + } + + // Clamps value between 0 and 1 and returns value + public static float Clamp01(float value) + { + if (value < 0F) + { + return 0F; + } + else if (value > 1F) + { + return 1F; + } + else + { + return value; + } + } + + // Interpolates between /a/ and /b/ by /t/. /t/ is clamped between 0 and 1. + public static float Lerp(float a, float b, float t) + { + return a + (b - a) * Clamp01(t); + } + + // Interpolates between /a/ and /b/ by /t/ without clamping the interpolant. + public static float LerpUnclamped(float a, float b, float t) + { + return a + (b - a) * t; + } + + // Same as ::ref::Lerp but makes sure the values interpolate correctly when they wrap around 360 degrees. + public static float LerpAngle(float a, float b, float t) + { + float delta = Repeat((b - a), 360); + if (delta > 180) + { + delta -= 360; + } + + return a + delta * Clamp01(t); + } + + // Moves a value /current/ towards /target/. + public static float MoveTowards(float current, float target, float maxDelta) + { + if (MathF.Abs(target - current) <= maxDelta) + { + return target; + } + + return current + MathF.Sign(target - current) * maxDelta; + } + + // Same as ::ref::MoveTowards but makes sure the values interpolate correctly when they wrap around 360 degrees. + public static float MoveTowardsAngle(float current, float target, float maxDelta) + { + float deltaAngle = DeltaAngle(current, target); + if (-maxDelta < deltaAngle && deltaAngle < maxDelta) + { + return target; + } + + target = current + deltaAngle; + return MoveTowards(current, target, maxDelta); + } + + // Interpolates between /min/ and /max/ with smoothing at the limits. + public static float SmoothStep(float from, float to, float t) + { + t = MathF.Clamp01(t); + t = -2.0F * t * t * t + 3.0F * t * t; + return to * t + from * (1F - t); + } + + //*undocumented + public static float Gamma(float value, float absmax, float gamma) + { + bool negative = value < 0F; + float absval = Abs(value); + if (absval > absmax) + { + return negative ? -absval : absval; + } + + float result = Pow(absval / absmax, gamma) * absmax; + return negative ? -result : result; + } + + // Loops the value t, so that it is never larger than length and never smaller than 0. + public static float Repeat(float t, float length) + { + return Clamp(t - MathF.Floor(t / length) * length, 0.0f, length); + } + + // PingPongs the value t, so that it is never larger than length and never smaller than 0. + public static float PingPong(float t, float length) + { + t = Repeat(t, length * 2F); + return length - MathF.Abs(t - length); + } + + // Calculates the ::ref::Lerp parameter between of two values. + public static float InverseLerp(float a, float b, float value) + { + if (a != b) + { + return Clamp01((value - a) / (b - a)); + } + else + { + return 0.0f; + } + } + + // Calculates the shortest difference between two given angles. + public static float DeltaAngle(float current, float target) + { + float delta = MathF.Repeat((target - current), 360.0F); + if (delta > 180.0F) + { + delta -= 360.0F; + } + + return delta; + } + + internal static long RandomToLong(System.Random r) + { + byte[] buffer = new byte[8]; + r.NextBytes(buffer); + return (long)(System.BitConverter.ToUInt64(buffer, 0) & long.MaxValue); + } + } +} diff --git a/LightlessSync/ThirdParty/Nanomesh/Base/MathUtils.cs b/LightlessSync/ThirdParty/Nanomesh/Base/MathUtils.cs new file mode 100644 index 0000000..9c49ae0 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Base/MathUtils.cs @@ -0,0 +1,114 @@ +using System.Runtime.CompilerServices; + +namespace Nanomesh +{ + public static class MathUtils + { + public const float EpsilonFloat = 1e-15f; + public const double EpsilonDouble = 1e-40f; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static float DivideSafe(float numerator, float denominator) + { + return (denominator > -EpsilonFloat && denominator < EpsilonFloat) ? 0f : numerator / denominator; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double DivideSafe(double numerator, double denominator) + { + return (denominator > -EpsilonDouble && denominator < EpsilonDouble) ? 0d : numerator / denominator; + } + + public static void SelectMin(double e1, double e2, double e3, in T v1, in T v2, in T v3, out double e, out T v) + { + if (e1 < e2) + { + if (e1 < e3) + { + e = e1; + v = v1; + } + else + { + e = e3; + v = v3; + } + } + else + { + if (e2 < e3) + { + e = e2; + v = v2; + } + else + { + e = e3; + v = v3; + } + } + } + + public static void SelectMin(double e1, double e2, double e3, double e4, in T v1, in T v2, in T v3, in T v4, out double e, out T v) + { + if (e1 < e2) + { + if (e1 < e3) + { + if (e1 < e4) + { + e = e1; + v = v1; + } + else + { + e = e4; + v = v4; + } + } + else + { + if (e3 < e4) + { + e = e3; + v = v3; + } + else + { + e = e4; + v = v4; + } + } + } + else + { + if (e2 < e3) + { + if (e2 < e4) + { + e = e2; + v = v2; + } + else + { + e = e4; + v = v4; + } + } + else + { + if (e3 < e4) + { + e = e3; + v = v3; + } + else + { + e = e4; + v = v4; + } + } + } + } + } +} diff --git a/LightlessSync/ThirdParty/Nanomesh/Base/Profiling.cs b/LightlessSync/ThirdParty/Nanomesh/Base/Profiling.cs new file mode 100644 index 0000000..d102493 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Base/Profiling.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Nanomesh +{ + public static class Profiling + { + private static readonly Dictionary stopwatches = new Dictionary(); + + public static void Start(string key) + { + if (!stopwatches.ContainsKey(key)) + { + stopwatches.Add(key, Stopwatch.StartNew()); + } + else + { + stopwatches[key] = Stopwatch.StartNew(); + } + } + + public static string End(string key) + { + TimeSpan time = EndTimer(key); + return $"{key} done in {time.ToString("mm':'ss':'fff")}"; + } + + private static TimeSpan EndTimer(string key) + { + if (!stopwatches.ContainsKey(key)) + { + return TimeSpan.MinValue; + } + + Stopwatch sw = stopwatches[key]; + sw.Stop(); + stopwatches.Remove(key); + return sw.Elapsed; + } + + public static TimeSpan Time(Action toTime) + { + Stopwatch timer = Stopwatch.StartNew(); + toTime(); + timer.Stop(); + return timer.Elapsed; + } + } +} diff --git a/LightlessSync/ThirdParty/Nanomesh/Base/Quaternion.cs b/LightlessSync/ThirdParty/Nanomesh/Base/Quaternion.cs new file mode 100644 index 0000000..d819f21 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Base/Quaternion.cs @@ -0,0 +1,632 @@ +using System; +using System.Runtime.InteropServices; + +namespace Nanomesh +{ + [StructLayout(LayoutKind.Sequential)] + public partial struct Quaternion : IEquatable + { + private const double radToDeg = 180.0 / Math.PI; + private const double degToRad = Math.PI / 180.0; + + public const double kEpsilon = 1E-20; // should probably be used in the 0 tests in LookRotation or Slerp + + public Vector3 xyz + { + set + { + x = value.x; + y = value.y; + z = value.z; + } + get => new Vector3(x, y, z); + } + + public double x; + + public double y; + + public double z; + + public double w; + + public double this[int index] + { + get + { + switch (index) + { + case 0: + return x; + case 1: + return y; + case 2: + return z; + case 3: + return w; + default: + throw new IndexOutOfRangeException("Invalid Quaternion index: " + index + ", can use only 0,1,2,3"); + } + } + set + { + switch (index) + { + case 0: + x = value; + break; + case 1: + y = value; + break; + case 2: + z = value; + break; + case 3: + w = value; + break; + default: + throw new IndexOutOfRangeException("Invalid Quaternion index: " + index + ", can use only 0,1,2,3"); + } + } + } + /// + /// The identity rotation (RO). + /// + public static Quaternion identity => new Quaternion(0, 0, 0, 1); + + /// + /// Gets the length (magnitude) of the quaternion. + /// + /// + public double Length => (double)System.Math.Sqrt(x * x + y * y + z * z + w * w); + + /// + /// Gets the square of the quaternion length (magnitude). + /// + public double LengthSquared => x * x + y * y + z * z + w * w; + + /// + /// Constructs new Quaternion with given x,y,z,w components. + /// + /// + /// + /// + /// + public Quaternion(double x, double y, double z, double w) + { + this.x = x; + this.y = y; + this.z = z; + this.w = w; + } + + /// + /// Construct a new Quaternion from vector and w components + /// + /// The vector part + /// The w part + public Quaternion(Vector3 v, double w) + { + x = v.x; + y = v.y; + z = v.z; + this.w = w; + } + + /// + /// Set x, y, z and w components of an existing Quaternion. + /// + /// + /// + /// + /// + public void Set(double new_x, double new_y, double new_z, double new_w) + { + x = new_x; + y = new_y; + z = new_z; + w = new_w; + } + + /// + /// Scales the Quaternion to unit length. + /// + public static Quaternion Normalize(Quaternion q) + { + double mag = Math.Sqrt(Dot(q, q)); + + if (mag < kEpsilon) + { + return Quaternion.identity; + } + + return new Quaternion(q.x / mag, q.y / mag, q.z / mag, q.w / mag); + } + + /// + /// Scale the given quaternion to unit length + /// + /// The quaternion to normalize + /// The normalized quaternion + public void Normalize() + { + this = Normalize(this); + } + + /// + /// The dot product between two rotations. + /// + /// + /// + public static double Dot(Quaternion a, Quaternion b) + { + return a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w; + } + + /// + /// Creates a rotation which rotates /angle/ degrees around /axis/. + /// + /// + /// + public static Quaternion AngleAxis(double angle, Vector3 axis) + { + return Quaternion.AngleAxis(angle, ref axis); + } + + private static Quaternion AngleAxis(double degress, ref Vector3 axis) + { + if (axis.LengthSquared == 0.0) + { + return identity; + } + + Quaternion result = identity; + double radians = degress * degToRad; + radians *= 0.5; + axis = axis.Normalized; + axis = axis * Math.Sin(radians); + result.x = axis.x; + result.y = axis.y; + result.z = axis.z; + result.w = Math.Cos(radians); + + return Normalize(result); + } + + public void ToAngleAxis(out double angle, out Vector3 axis) + { + Quaternion.ToAxisAngleRad(this, out axis, out angle); + angle *= radToDeg; + } + + /// + /// Creates a rotation which rotates from /fromDirection/ to /toDirection/. + /// + /// + /// + public static Quaternion FromToRotation(Vector3 fromDirection, Vector3 toDirection) + { + return RotateTowards(LookRotation(fromDirection), LookRotation(toDirection), double.MaxValue); + } + + /// + /// Creates a rotation which rotates from /fromDirection/ to /toDirection/. + /// + /// + /// + public void SetFromToRotation(Vector3 fromDirection, Vector3 toDirection) + { + this = Quaternion.FromToRotation(fromDirection, toDirection); + } + + /// + /// Creates a rotation with the specified /forward/ and /upwards/ directions. + /// + /// The direction to look in. + /// The vector that defines in which direction up is. + public static Quaternion LookRotation(Vector3 forward, Vector3 upwards) + { + return Quaternion.LookRotation(ref forward, ref upwards); + } + + public static Quaternion LookRotation(Vector3 forward) + { + Vector3 up = new Vector3(1, 0, 0); + return Quaternion.LookRotation(ref forward, ref up); + } + + private static Quaternion LookRotation(ref Vector3 forward, ref Vector3 up) + { + forward = Vector3.Normalize(forward); + Vector3 right = Vector3.Normalize(Vector3.Cross(up, forward)); + up = Vector3.Cross(forward, right); + double m00 = right.x; + double m01 = right.y; + double m02 = right.z; + double m10 = up.x; + double m11 = up.y; + double m12 = up.z; + double m20 = forward.x; + double m21 = forward.y; + double m22 = forward.z; + + double num8 = (m00 + m11) + m22; + Quaternion quaternion = new Quaternion(); + if (num8 > 0) + { + double num = Math.Sqrt(num8 + 1); + quaternion.w = num * 0.5; + num = 0.5 / num; + quaternion.x = (m12 - m21) * num; + quaternion.y = (m20 - m02) * num; + quaternion.z = (m01 - m10) * num; + return quaternion; + } + if ((m00 >= m11) && (m00 >= m22)) + { + double num7 = Math.Sqrt(((1 + m00) - m11) - m22); + double num4 = 0.5 / num7; + quaternion.x = 0.5 * num7; + quaternion.y = (m01 + m10) * num4; + quaternion.z = (m02 + m20) * num4; + quaternion.w = (m12 - m21) * num4; + return quaternion; + } + if (m11 > m22) + { + double num6 = Math.Sqrt(((1 + m11) - m00) - m22); + double num3 = 0.5 / num6; + quaternion.x = (m10 + m01) * num3; + quaternion.y = 0.5 * num6; + quaternion.z = (m21 + m12) * num3; + quaternion.w = (m20 - m02) * num3; + return quaternion; + } + double num5 = Math.Sqrt(((1 + m22) - m00) - m11); + double num2 = 0.5 / num5; + quaternion.x = (m20 + m02) * num2; + quaternion.y = (m21 + m12) * num2; + quaternion.z = 0.5 * num5; + quaternion.w = (m01 - m10) * num2; + return quaternion; + } + + public void SetLookRotation(Vector3 view) + { + Vector3 up = new Vector3(1, 0, 0); + SetLookRotation(view, up); + } + + /// + /// Creates a rotation with the specified /forward/ and /upwards/ directions. + /// + /// The direction to look in. + /// The vector that defines in which direction up is. + public void SetLookRotation(Vector3 view, Vector3 up) + { + this = Quaternion.LookRotation(view, up); + } + + /// + /// Spherically interpolates between /a/ and /b/ by t. The parameter /t/ is clamped to the range [0, 1]. + /// + /// + /// + /// + public static Quaternion Slerp(Quaternion a, Quaternion b, double t) + { + return Quaternion.Slerp(ref a, ref b, t); + } + + private static Quaternion Slerp(ref Quaternion a, ref Quaternion b, double t) + { + if (t > 1) + { + t = 1; + } + + if (t < 0) + { + t = 0; + } + + return SlerpUnclamped(ref a, ref b, t); + } + + /// + /// Spherically interpolates between /a/ and /b/ by t. The parameter /t/ is not clamped. + /// + /// + /// + /// + public static Quaternion SlerpUnclamped(Quaternion a, Quaternion b, double t) + { + + return Quaternion.SlerpUnclamped(ref a, ref b, t); + } + private static Quaternion SlerpUnclamped(ref Quaternion a, ref Quaternion b, double t) + { + // if either input is zero, return the other. + if (a.LengthSquared == 0.0) + { + if (b.LengthSquared == 0.0) + { + return identity; + } + return b; + } + else if (b.LengthSquared == 0.0) + { + return a; + } + + double cosHalfAngle = a.w * b.w + Vector3.Dot(a.xyz, b.xyz); + + if (cosHalfAngle >= 1.0 || cosHalfAngle <= -1.0) + { + // angle = 0.0f, so just return one input. + return a; + } + else if (cosHalfAngle < 0.0) + { + b.xyz = -b.xyz; + b.w = -b.w; + cosHalfAngle = -cosHalfAngle; + } + + double blendA; + double blendB; + if (cosHalfAngle < 0.99) + { + // do proper slerp for big angles + double halfAngle = Math.Acos(cosHalfAngle); + double sinHalfAngle = Math.Sin(halfAngle); + double oneOverSinHalfAngle = 1.0 / sinHalfAngle; + blendA = Math.Sin(halfAngle * (1.0 - t)) * oneOverSinHalfAngle; + blendB = Math.Sin(halfAngle * t) * oneOverSinHalfAngle; + } + else + { + // do lerp if angle is really small. + blendA = 1.0f - t; + blendB = t; + } + + Quaternion result = new Quaternion(blendA * a.xyz + blendB * b.xyz, blendA * a.w + blendB * b.w); + if (result.LengthSquared > 0.0) + { + return Normalize(result); + } + else + { + return identity; + } + } + + /// + /// Interpolates between /a/ and /b/ by /t/ and normalizes the result afterwards. The parameter /t/ is clamped to the range [0, 1]. + /// + /// + /// + /// + public static Quaternion Lerp(Quaternion a, Quaternion b, double t) + { + if (t > 1) + { + t = 1; + } + + if (t < 0) + { + t = 0; + } + + return Slerp(ref a, ref b, t); // TODO: use lerp not slerp, "Because quaternion works in 4D. Rotation in 4D are linear" ??? + } + + /// + /// Interpolates between /a/ and /b/ by /t/ and normalizes the result afterwards. The parameter /t/ is not clamped. + /// + /// + /// + /// + public static Quaternion LerpUnclamped(Quaternion a, Quaternion b, double t) + { + return Slerp(ref a, ref b, t); + } + + /// + /// Rotates a rotation /from/ towards /to/. + /// + /// + /// + /// + public static Quaternion RotateTowards(Quaternion from, Quaternion to, double maxDegreesDelta) + { + double num = Quaternion.Angle(from, to); + if (num == 0) + { + return to; + } + double t = Math.Min(1, maxDegreesDelta / num); + return Quaternion.SlerpUnclamped(from, to, t); + } + + /// + /// Returns the Inverse of /rotation/. + /// + /// + public static Quaternion Inverse(Quaternion rotation) + { + double lengthSq = rotation.LengthSquared; + if (lengthSq != 0.0) + { + double i = 1.0 / lengthSq; + return new Quaternion(rotation.xyz * -i, rotation.w * i); + } + return rotation; + } + + /// + /// Returns a nicely formatted string of the Quaternion. + /// + /// + public override string ToString() + { + return $"{x}, {y}, {z}, {w}"; + } + + /// + /// Returns a nicely formatted string of the Quaternion. + /// + /// + public string ToString(string format) + { + return string.Format("({0}, {1}, {2}, {3})", x.ToString(format), y.ToString(format), z.ToString(format), w.ToString(format)); + } + + /// + /// Returns the angle in degrees between two rotations /a/ and /b/. + /// + /// + /// + public static double Angle(Quaternion a, Quaternion b) + { + double f = Quaternion.Dot(a, b); + return Math.Acos(Math.Min(Math.Abs(f), 1)) * 2 * radToDeg; + } + + /// + /// Returns a rotation that rotates z degrees around the z axis, x degrees around the x axis, and y degrees around the y axis (in that order). + /// + /// + /// + /// + public static Quaternion Euler(double x, double y, double z) + { + return Quaternion.FromEulerRad(new Vector3((double)x, (double)y, (double)z) * degToRad); + } + + /// + /// Returns a rotation that rotates z degrees around the z axis, x degrees around the x axis, and y degrees around the y axis (in that order). + /// + /// + public static Quaternion Euler(Vector3 euler) + { + return Quaternion.FromEulerRad(euler * degToRad); + } + + private static double NormalizeAngle(double angle) + { + while (angle > 360) + { + angle -= 360; + } + + while (angle < 0) + { + angle += 360; + } + + return angle; + } + + private static Quaternion FromEulerRad(Vector3 euler) + { + double yaw = euler.x; + double pitch = euler.y; + double roll = euler.z; + double rollOver2 = roll * 0.5; + double sinRollOver2 = (double)System.Math.Sin((double)rollOver2); + double cosRollOver2 = (double)System.Math.Cos((double)rollOver2); + double pitchOver2 = pitch * 0.5; + double sinPitchOver2 = (double)System.Math.Sin((double)pitchOver2); + double cosPitchOver2 = (double)System.Math.Cos((double)pitchOver2); + double yawOver2 = yaw * 0.5; + double sinYawOver2 = (double)System.Math.Sin((double)yawOver2); + double cosYawOver2 = (double)System.Math.Cos((double)yawOver2); + Quaternion result; + result.x = cosYawOver2 * cosPitchOver2 * cosRollOver2 + sinYawOver2 * sinPitchOver2 * sinRollOver2; + result.y = cosYawOver2 * cosPitchOver2 * sinRollOver2 - sinYawOver2 * sinPitchOver2 * cosRollOver2; + result.z = cosYawOver2 * sinPitchOver2 * cosRollOver2 + sinYawOver2 * cosPitchOver2 * sinRollOver2; + result.w = sinYawOver2 * cosPitchOver2 * cosRollOver2 - cosYawOver2 * sinPitchOver2 * sinRollOver2; + return result; + } + + private static void ToAxisAngleRad(Quaternion q, out Vector3 axis, out double angle) + { + if (System.Math.Abs(q.w) > 1.0) + { + q.Normalize(); + } + + angle = 2.0f * (double)System.Math.Acos(q.w); // angle + double den = (double)System.Math.Sqrt(1.0 - q.w * q.w); + if (den > 0.0001) + { + axis = q.xyz / den; + } + else + { + // This occurs when the angle is zero. + // Not a problem: just set an arbitrary normalized axis. + axis = new Vector3(1, 0, 0); + } + } + + public override int GetHashCode() + { + return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2 ^ w.GetHashCode() >> 1; + } + public override bool Equals(object other) + { + if (!(other is Quaternion)) + { + return false; + } + Quaternion quaternion = (Quaternion)other; + return x.Equals(quaternion.x) && y.Equals(quaternion.y) && z.Equals(quaternion.z) && w.Equals(quaternion.w); + } + + public bool Equals(Quaternion other) + { + return x.Equals(other.x) && y.Equals(other.y) && z.Equals(other.z) && w.Equals(other.w); + } + + public static Quaternion operator *(Quaternion lhs, Quaternion rhs) + { + return new Quaternion(lhs.w * rhs.x + lhs.x * rhs.w + lhs.y * rhs.z - lhs.z * rhs.y, lhs.w * rhs.y + lhs.y * rhs.w + lhs.z * rhs.x - lhs.x * rhs.z, lhs.w * rhs.z + lhs.z * rhs.w + lhs.x * rhs.y - lhs.y * rhs.x, lhs.w * rhs.w - lhs.x * rhs.x - lhs.y * rhs.y - lhs.z * rhs.z); + } + + public static Vector3 operator *(Quaternion rotation, Vector3 point) + { + double num = rotation.x * 2; + double num2 = rotation.y * 2; + double num3 = rotation.z * 2; + double num4 = rotation.x * num; + double num5 = rotation.y * num2; + double num6 = rotation.z * num3; + double num7 = rotation.x * num2; + double num8 = rotation.x * num3; + double num9 = rotation.y * num3; + double num10 = rotation.w * num; + double num11 = rotation.w * num2; + double num12 = rotation.w * num3; + + return new Vector3( + (1 - (num5 + num6)) * point.x + (num7 - num12) * point.y + (num8 + num11) * point.z, + (num7 + num12) * point.x + (1 - (num4 + num6)) * point.y + (num9 - num10) * point.z, + (num8 - num11) * point.x + (num9 + num10) * point.y + (1 - (num4 + num5)) * point.z); + } + + public static bool operator ==(Quaternion lhs, Quaternion rhs) + { + return Quaternion.Dot(lhs, rhs) > 0.999999999; + } + + public static bool operator !=(Quaternion lhs, Quaternion rhs) + { + return Quaternion.Dot(lhs, rhs) <= 0.999999999; + } + } +} diff --git a/LightlessSync/ThirdParty/Nanomesh/Base/SymmetricMatrix.cs b/LightlessSync/ThirdParty/Nanomesh/Base/SymmetricMatrix.cs new file mode 100644 index 0000000..d7be6a4 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Base/SymmetricMatrix.cs @@ -0,0 +1,97 @@ +namespace Nanomesh +{ + public readonly struct SymmetricMatrix + { + public readonly double m0, m1, m2, m3, m4, m5, m6, m7, m8, m9; + + public SymmetricMatrix(in double m0, in double m1, in double m2, in double m3, in double m4, in double m5, in double m6, in double m7, in double m8, in double m9) + { + this.m0 = m0; + this.m1 = m1; + this.m2 = m2; + this.m3 = m3; + this.m4 = m4; + this.m5 = m5; + this.m6 = m6; + this.m7 = m7; + this.m8 = m8; + this.m9 = m9; + } + + public SymmetricMatrix(in double a, in double b, in double c, in double d) + { + m0 = a * a; + m1 = a * b; + m2 = a * c; + m3 = a * d; + + m4 = b * b; + m5 = b * c; + m6 = b * d; + + m7 = c * c; + m8 = c * d; + + m9 = d * d; + } + + public static SymmetricMatrix operator +(in SymmetricMatrix a, in SymmetricMatrix b) + { + return new SymmetricMatrix( + a.m0 + b.m0, a.m1 + b.m1, a.m2 + b.m2, a.m3 + b.m3, + a.m4 + b.m4, a.m5 + b.m5, a.m6 + b.m6, + a.m7 + b.m7, a.m8 + b.m8, + a.m9 + b.m9 + ); + } + + public double DeterminantXYZ() + { + return + m0 * m4 * m7 + + m2 * m1 * m5 + + m1 * m5 * m2 - + m2 * m4 * m2 - + m0 * m5 * m5 - + m1 * m1 * m7; + } + + public double DeterminantX() + { + return + m1 * m5 * m8 + + m3 * m4 * m7 + + m2 * m6 * m5 - + m3 * m5 * m5 - + m1 * m6 * m7 - + m2 * m4 * m8; + } + + public double DeterminantY() + { + return + m0 * m5 * m8 + + m3 * m1 * m7 + + m2 * m6 * m2 - + m3 * m5 * m2 - + m0 * m6 * m7 - + m2 * m1 * m8; + } + + public double DeterminantZ() + { + return + m0 * m4 * m8 + + m3 * m1 * m5 + + m1 * m6 * m2 - + m3 * m4 * m2 - + m0 * m6 * m5 - + m1 * m1 * m8; + } + + public override string ToString() + { + return $"{m0} {m1} {m2} {m3}| {m4} {m5} {m6} | {m7} {m8} | {m9}"; + } + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Base/TextUtils.cs b/LightlessSync/ThirdParty/Nanomesh/Base/TextUtils.cs new file mode 100644 index 0000000..6669eec --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Base/TextUtils.cs @@ -0,0 +1,26 @@ +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace Nanomesh +{ + public static class TextUtils + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double ToDouble(this string text) + { + return double.Parse(text, CultureInfo.InvariantCulture); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static float ToFloat(this string text) + { + return float.Parse(text, CultureInfo.InvariantCulture); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int ToInt(this string text) + { + return int.Parse(text, CultureInfo.InvariantCulture); + } + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Base/Vector2.cs b/LightlessSync/ThirdParty/Nanomesh/Base/Vector2.cs new file mode 100644 index 0000000..484d8ba --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Base/Vector2.cs @@ -0,0 +1,377 @@ +using System; + +namespace Nanomesh +{ + public readonly struct Vector2 : IEquatable, IInterpolable + { + public readonly double x; + public readonly double y; + + // Access the /x/ or /y/ component using [0] or [1] respectively. + public double this[int index] + { + get + { + switch (index) + { + case 0: return x; + case 1: return y; + default: + throw new IndexOutOfRangeException("Invalid Vector2 index!"); + } + } + } + + // Constructs a new vector with given x, y components. + public Vector2(double x, double y) { this.x = x; this.y = y; } + + // Linearly interpolates between two vectors. + public static Vector2 Lerp(Vector2 a, Vector2 b, double t) + { + t = MathF.Clamp(t, 0, 1); + return new Vector2( + a.x + (b.x - a.x) * t, + a.y + (b.y - a.y) * t + ); + } + + // Linearly interpolates between two vectors without clamping the interpolant + public static Vector2 LerpUnclamped(Vector2 a, Vector2 b, double t) + { + return new Vector2( + a.x + (b.x - a.x) * t, + a.y + (b.y - a.y) * t + ); + } + + // Moves a point /current/ towards /target/. + public static Vector2 MoveTowards(Vector2 current, Vector2 target, double maxDistanceDelta) + { + // avoid vector ops because current scripting backends are terrible at inlining + double toVector_x = target.x - current.x; + double toVector_y = target.y - current.y; + + double sqDist = toVector_x * toVector_x + toVector_y * toVector_y; + + if (sqDist == 0 || (maxDistanceDelta >= 0 && sqDist <= maxDistanceDelta * maxDistanceDelta)) + { + return target; + } + + double dist = Math.Sqrt(sqDist); + + return new Vector2(current.x + toVector_x / dist * maxDistanceDelta, + current.y + toVector_y / dist * maxDistanceDelta); + } + + // Multiplies two vectors component-wise. + public static Vector2 Scale(Vector2 a, Vector2 b) => new Vector2(a.x * b.x, a.y * b.y); + + public static Vector2 Normalize(in Vector2 value) + { + double mag = Magnitude(in value); + if (mag > K_EPSILON) + { + return value / mag; + } + else + { + return Zero; + } + } + + public Vector2 Normalize() => Normalize(in this); + + public static double SqrMagnitude(in Vector2 a) => a.x * a.x + a.y * a.y; + + /// + /// Returns the squared length of this vector (RO). + /// + public double SqrMagnitude() => SqrMagnitude(in this); + + public static double Magnitude(in Vector2 vector) => Math.Sqrt(SqrMagnitude(in vector)); + + public double Magnitude() => Magnitude(this); + + // used to allow Vector2s to be used as keys in hash tables + public override int GetHashCode() + { + return x.GetHashCode() ^ (y.GetHashCode() << 2); + } + + // also required for being able to use Vector2s as keys in hash tables + public override bool Equals(object other) + { + if (!(other is Vector2)) + { + return false; + } + + return Equals((Vector2)other); + } + + + public bool Equals(Vector2 other) + { + return x == other.x && y == other.y; + } + + public static Vector2 Reflect(Vector2 inDirection, Vector2 inNormal) + { + double factor = -2F * Dot(inNormal, inDirection); + return new Vector2(factor * inNormal.x + inDirection.x, factor * inNormal.y + inDirection.y); + } + + + public static Vector2 Perpendicular(Vector2 inDirection) + { + return new Vector2(-inDirection.y, inDirection.x); + } + + /// + /// Returns the dot Product of two vectors. + /// + /// + /// + /// + public static double Dot(Vector2 lhs, Vector2 rhs) { return lhs.x * rhs.x + lhs.y * rhs.y; } + + /// + /// Returns the angle in radians between /from/ and /to/. + /// + /// + /// + /// + public static double AngleRadians(Vector2 from, Vector2 to) + { + // sqrt(a) * sqrt(b) = sqrt(a * b) -- valid for real numbers + double denominator = Math.Sqrt(from.SqrMagnitude() * to.SqrMagnitude()); + if (denominator < K_EPSILON_NORMAL_SQRT) + { + return 0F; + } + + double dot = MathF.Clamp(Dot(from, to) / denominator, -1F, 1F); + return Math.Acos(dot); + } + + public static double AngleDegrees(Vector2 from, Vector2 to) + { + return AngleRadians(from, to) / MathF.PI * 180f; + } + + /// + /// Returns the signed angle in degrees between /from/ and /to/. Always returns the smallest possible angle + /// + /// + /// + /// + public static double SignedAngle(Vector2 from, Vector2 to) + { + double unsigned_angle = AngleDegrees(from, to); + double sign = Math.Sign(from.x * to.y - from.y * to.x); + return unsigned_angle * sign; + } + + /// + /// Returns the distance between /a/ and /b/. + /// + /// + /// + /// + public static double Distance(Vector2 a, Vector2 b) + { + double diff_x = a.x - b.x; + double diff_y = a.y - b.y; + return Math.Sqrt(diff_x * diff_x + diff_y * diff_y); + } + + /// + /// Returns a copy of /vector/ with its magnitude clamped to /maxLength/. + /// + /// + /// + /// + public static Vector2 ClampMagnitude(Vector2 vector, double maxLength) + { + double sqrMagnitude = vector.SqrMagnitude(); + if (sqrMagnitude > maxLength * maxLength) + { + double mag = Math.Sqrt(sqrMagnitude); + + //these intermediate variables force the intermediate result to be + //of double precision. without this, the intermediate result can be of higher + //precision, which changes behavior. + double normalized_x = vector.x / mag; + double normalized_y = vector.y / mag; + return new Vector2(normalized_x * maxLength, + normalized_y * maxLength); + } + return vector; + } + + /// + /// Returns a vector that is made from the smallest components of two vectors. + /// + /// + /// + /// + public static Vector2 Min(Vector2 lhs, Vector2 rhs) { return new Vector2(Math.Min(lhs.x, rhs.x), Math.Min(lhs.y, rhs.y)); } + + /// + /// Returns a vector that is made from the largest components of two vectors. + /// + /// + /// + /// + public static Vector2 Max(Vector2 lhs, Vector2 rhs) { return new Vector2(Math.Max(lhs.x, rhs.x), Math.Max(lhs.y, rhs.y)); } + + public Vector2 Interpolate(Vector2 other, double ratio) => this * ratio + other * (1 - ratio); + + /// + /// Adds two vectors. + /// + /// + /// + /// + public static Vector2 operator +(Vector2 a, Vector2 b) { return new Vector2(a.x + b.x, a.y + b.y); } + + /// + /// Subtracts one vector from another. + /// + /// + /// + /// + public static Vector2 operator -(Vector2 a, Vector2 b) { return new Vector2(a.x - b.x, a.y - b.y); } + + /// + /// Multiplies one vector by another. + /// + /// + /// + /// + public static Vector2 operator *(Vector2 a, Vector2 b) { return new Vector2(a.x * b.x, a.y * b.y); } + + /// + /// Divides one vector over another. + /// + /// + /// + /// + public static Vector2 operator /(Vector2 a, Vector2 b) { return new Vector2(a.x / b.x, a.y / b.y); } + + /// + /// Negates a vector. + /// + /// + /// + public static Vector2 operator -(Vector2 a) { return new Vector2(-a.x, -a.y); } + + /// + /// Multiplies a vector by a number. + /// + /// + /// + /// + public static Vector2 operator *(Vector2 a, double d) { return new Vector2(a.x * d, a.y * d); } + + /// + /// Multiplies a vector by a number. + /// + /// + /// + /// + public static Vector2 operator *(double d, Vector2 a) { return new Vector2(a.x * d, a.y * d); } + + /// + /// Divides a vector by a number. + /// + /// + /// + /// + public static Vector2 operator /(Vector2 a, double d) { return new Vector2(a.x / d, a.y / d); } + + /// + /// Returns true if the vectors are equal. + /// + /// + /// + /// + public static bool operator ==(Vector2 lhs, Vector2 rhs) + { + // Returns false in the presence of NaN values. + double diff_x = lhs.x - rhs.x; + double diff_y = lhs.y - rhs.y; + return (diff_x * diff_x + diff_y * diff_y) < K_EPSILON * K_EPSILON; + } + + /// + /// Returns true if vectors are different. + /// + /// + /// + /// + public static bool operator !=(Vector2 lhs, Vector2 rhs) + { + // Returns true in the presence of NaN values. + return !(lhs == rhs); + } + + /// + /// Converts a [[Vector3]] to a Vector2. + /// + /// + public static implicit operator Vector2(Vector3F v) + { + return new Vector2(v.x, v.y); + } + + /// + /// Converts a Vector2 to a [[Vector3]]. + /// + /// + public static implicit operator Vector3(Vector2 v) + { + return new Vector3(v.x, v.y, 0); + } + + public static implicit operator Vector2F(Vector2 vec) + { + return new Vector2F((float)vec.x, (float)vec.y); + } + + public static explicit operator Vector2(Vector2F vec) + { + return new Vector2(vec.x, vec.y); + } + + public static readonly Vector2 zeroVector = new Vector2(0F, 0F); + public static readonly Vector2 oneVector = new Vector2(1F, 1F); + public static readonly Vector2 upVector = new Vector2(0F, 1F); + public static readonly Vector2 downVector = new Vector2(0F, -1F); + public static readonly Vector2 leftVector = new Vector2(-1F, 0F); + public static readonly Vector2 rightVector = new Vector2(1F, 0F); + public static readonly Vector2 positiveInfinityVector = new Vector2(double.PositiveInfinity, double.PositiveInfinity); + public static readonly Vector2 negativeInfinityVector = new Vector2(double.NegativeInfinity, double.NegativeInfinity); + + public static Vector2 Zero => zeroVector; + + public static Vector2 One => oneVector; + + public static Vector2 Up => upVector; + + public static Vector2 Down => downVector; + + public static Vector2 Left => leftVector; + + public static Vector2 Right => rightVector; + + public static Vector2 PositiveInfinity => positiveInfinityVector; + + public static Vector2 NegativeInfinity => negativeInfinityVector; + + public const double K_EPSILON = 0.00001F; + + public const double K_EPSILON_NORMAL_SQRT = 1e-15f; + } +} diff --git a/LightlessSync/ThirdParty/Nanomesh/Base/Vector2F.cs b/LightlessSync/ThirdParty/Nanomesh/Base/Vector2F.cs new file mode 100644 index 0000000..9f3faa5 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Base/Vector2F.cs @@ -0,0 +1,371 @@ +using System; + +namespace Nanomesh +{ + public readonly struct Vector2F : IEquatable, IInterpolable + { + public readonly float x; + public readonly float y; + + // Access the /x/ or /y/ component using [0] or [1] respectively. + public float this[int index] + { + get + { + switch (index) + { + case 0: return x; + case 1: return y; + default: + throw new IndexOutOfRangeException("Invalid Vector2 index!"); + } + } + } + + // Constructs a new vector with given x, y components. + public Vector2F(float x, float y) { this.x = x; this.y = y; } + + // Linearly interpolates between two vectors. + public static Vector2F Lerp(Vector2F a, Vector2F b, float t) + { + t = MathF.Clamp(t, 0, 1); + return new Vector2F( + a.x + (b.x - a.x) * t, + a.y + (b.y - a.y) * t + ); + } + + // Linearly interpolates between two vectors without clamping the interpolant + public static Vector2F LerpUnclamped(Vector2F a, Vector2F b, float t) + { + return new Vector2F( + a.x + (b.x - a.x) * t, + a.y + (b.y - a.y) * t + ); + } + + // Moves a point /current/ towards /target/. + public static Vector2F MoveTowards(Vector2F current, Vector2F target, float maxDistanceDelta) + { + // avoid vector ops because current scripting backends are terrible at inlining + float toVector_x = target.x - current.x; + float toVector_y = target.y - current.y; + + float sqDist = toVector_x * toVector_x + toVector_y * toVector_y; + + if (sqDist == 0 || (maxDistanceDelta >= 0 && sqDist <= maxDistanceDelta * maxDistanceDelta)) + { + return target; + } + + float dist = MathF.Sqrt(sqDist); + + return new Vector2F(current.x + toVector_x / dist * maxDistanceDelta, + current.y + toVector_y / dist * maxDistanceDelta); + } + + // Multiplies two vectors component-wise. + public static Vector2F Scale(Vector2F a, Vector2F b) { return new Vector2F(a.x * b.x, a.y * b.y); } + + public static Vector2F Normalize(in Vector2F value) + { + float mag = Magnitude(in value); + if (mag > K_EPSILON) + { + return value / mag; + } + else + { + return Zero; + } + } + + public Vector2F Normalize() => Normalize(in this); + + public static float SqrMagnitude(in Vector2F a) => a.x * a.x + a.y * a.y; + + /// + /// Returns the squared length of this vector (RO). + /// + public float SqrMagnitude() => SqrMagnitude(in this); + + public static float Magnitude(in Vector2F vector) => (float)Math.Sqrt(SqrMagnitude(in vector)); + + public float Magnitude() => Magnitude(this); + + // used to allow Vector2s to be used as keys in hash tables + public override int GetHashCode() + { + return x.GetHashCode() ^ (y.GetHashCode() << 2); + } + + // also required for being able to use Vector2s as keys in hash tables + public override bool Equals(object other) + { + if (!(other is Vector2F)) + { + return false; + } + + return Equals((Vector2F)other); + } + + + public bool Equals(Vector2F other) + { + return Vector2FComparer.Default.Equals(this, other); + //return x == other.x && y == other.y; + } + + public static Vector2F Reflect(Vector2F inDirection, Vector2F inNormal) + { + float factor = -2F * Dot(inNormal, inDirection); + return new Vector2F(factor * inNormal.x + inDirection.x, factor * inNormal.y + inDirection.y); + } + + public static Vector2F Perpendicular(Vector2F inDirection) + { + return new Vector2F(-inDirection.y, inDirection.x); + } + + /// + /// Returns the dot Product of two vectors. + /// + /// + /// + /// + public static float Dot(Vector2F lhs, Vector2F rhs) { return lhs.x * rhs.x + lhs.y * rhs.y; } + + /// + /// Returns the angle in radians between /from/ and /to/. + /// + /// + /// + /// + public static float AngleRadians(Vector2F from, Vector2F to) + { + // sqrt(a) * sqrt(b) = sqrt(a * b) -- valid for real numbers + float denominator = MathF.Sqrt(from.SqrMagnitude() * to.SqrMagnitude()); + if (denominator < K_EPSILON_NORMAL_SQRT) + { + return 0F; + } + + float dot = MathF.Clamp(Dot(from, to) / denominator, -1F, 1F); + return MathF.Acos(dot); + } + + public static float AngleDegrees(Vector2F from, Vector2F to) + { + return AngleRadians(from, to) / MathF.PI * 180f; + } + + /// + /// Returns the signed angle in degrees between /from/ and /to/. Always returns the smallest possible angle + /// + /// + /// + /// + public static float SignedAngle(Vector2F from, Vector2F to) + { + float unsigned_angle = AngleDegrees(from, to); + float sign = MathF.Sign(from.x * to.y - from.y * to.x); + return unsigned_angle * sign; + } + + /// + /// Returns the distance between /a/ and /b/. + /// + /// + /// + /// + public static float Distance(Vector2F a, Vector2F b) + { + float diff_x = a.x - b.x; + float diff_y = a.y - b.y; + return MathF.Sqrt(diff_x * diff_x + diff_y * diff_y); + } + + /// + /// Returns a copy of /vector/ with its magnitude clamped to /maxLength/. + /// + /// + /// + /// + public static Vector2F ClampMagnitude(Vector2F vector, float maxLength) + { + float sqrMagnitude = vector.SqrMagnitude(); + if (sqrMagnitude > maxLength * maxLength) + { + float mag = MathF.Sqrt(sqrMagnitude); + + //these intermediate variables force the intermediate result to be + //of float precision. without this, the intermediate result can be of higher + //precision, which changes behavior. + float normalized_x = vector.x / mag; + float normalized_y = vector.y / mag; + return new Vector2F(normalized_x * maxLength, + normalized_y * maxLength); + } + return vector; + } + + /// + /// Returns a vector that is made from the smallest components of two vectors. + /// + /// + /// + /// + public static Vector2F Min(Vector2F lhs, Vector2F rhs) { return new Vector2F(MathF.Min(lhs.x, rhs.x), MathF.Min(lhs.y, rhs.y)); } + + /// + /// Returns a vector that is made from the largest components of two vectors. + /// + /// + /// + /// + public static Vector2F Max(Vector2F lhs, Vector2F rhs) { return new Vector2F(MathF.Max(lhs.x, rhs.x), MathF.Max(lhs.y, rhs.y)); } + + public Vector2F Interpolate(Vector2F other, double ratio) => this * ratio + other * (1 - ratio); + + /// + /// Adds two vectors. + /// + /// + /// + /// + public static Vector2F operator +(Vector2F a, Vector2F b) { return new Vector2F(a.x + b.x, a.y + b.y); } + + /// + /// Subtracts one vector from another. + /// + /// + /// + /// + public static Vector2F operator -(Vector2F a, Vector2F b) { return new Vector2F(a.x - b.x, a.y - b.y); } + + /// + /// Multiplies one vector by another. + /// + /// + /// + /// + public static Vector2F operator *(Vector2F a, Vector2F b) { return new Vector2F(a.x * b.x, a.y * b.y); } + + /// + /// Divides one vector over another. + /// + /// + /// + /// + public static Vector2F operator /(Vector2F a, Vector2F b) { return new Vector2F(a.x / b.x, a.y / b.y); } + + /// + /// Negates a vector. + /// + /// + /// + public static Vector2F operator -(Vector2F a) { return new Vector2F(-a.x, -a.y); } + + /// + /// Multiplies a vector by a number. + /// + /// + /// + /// + public static Vector2F operator *(Vector2F a, float d) { return new Vector2F(a.x * d, a.y * d); } + + public static Vector2 operator *(Vector2F a, double d) { return new Vector2(a.x * d, a.y * d); } + + /// + /// Multiplies a vector by a number. + /// + /// + /// + /// + public static Vector2F operator *(float d, Vector2F a) { return new Vector2F(a.x * d, a.y * d); } + + public static Vector2 operator *(double d, Vector2F a) { return new Vector2(a.x * d, a.y * d); } + + /// + /// Divides a vector by a number. + /// + /// + /// + /// + public static Vector2F operator /(Vector2F a, float d) { return new Vector2F(a.x / d, a.y / d); } + + /// + /// Returns true if the vectors are equal. + /// + /// + /// + /// + public static bool operator ==(Vector2F lhs, Vector2F rhs) + { + // Returns false in the presence of NaN values. + float diff_x = lhs.x - rhs.x; + float diff_y = lhs.y - rhs.y; + return (diff_x * diff_x + diff_y * diff_y) < K_EPSILON * K_EPSILON; + } + + /// + /// Returns true if vectors are different. + /// + /// + /// + /// + public static bool operator !=(Vector2F lhs, Vector2F rhs) + { + // Returns true in the presence of NaN values. + return !(lhs == rhs); + } + + /// + /// Converts a [[Vector3]] to a Vector2. + /// + /// + public static implicit operator Vector2F(Vector3F v) + { + return new Vector2F(v.x, v.y); + } + + /// + /// Converts a Vector2 to a [[Vector3]]. + /// + /// + public static implicit operator Vector3(Vector2F v) + { + return new Vector3(v.x, v.y, 0); + } + + public static readonly Vector2F zeroVector = new Vector2F(0F, 0F); + public static readonly Vector2F oneVector = new Vector2F(1F, 1F); + public static readonly Vector2F upVector = new Vector2F(0F, 1F); + public static readonly Vector2F downVector = new Vector2F(0F, -1F); + public static readonly Vector2F leftVector = new Vector2F(-1F, 0F); + public static readonly Vector2F rightVector = new Vector2F(1F, 0F); + public static readonly Vector2F positiveInfinityVector = new Vector2F(float.PositiveInfinity, float.PositiveInfinity); + public static readonly Vector2F negativeInfinityVector = new Vector2F(float.NegativeInfinity, float.NegativeInfinity); + + public static Vector2F Zero => zeroVector; + + public static Vector2F One => oneVector; + + public static Vector2F Up => upVector; + + public static Vector2F Down => downVector; + + public static Vector2F Left => leftVector; + + public static Vector2F Right => rightVector; + + public static Vector2F PositiveInfinity => positiveInfinityVector; + + public static Vector2F NegativeInfinity => negativeInfinityVector; + + public const float K_EPSILON = 0.00001F; + + public const float K_EPSILON_NORMAL_SQRT = 1e-15f; + } +} diff --git a/LightlessSync/ThirdParty/Nanomesh/Base/Vector2FComparer.cs b/LightlessSync/ThirdParty/Nanomesh/Base/Vector2FComparer.cs new file mode 100644 index 0000000..2519aaf --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Base/Vector2FComparer.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; + +namespace Nanomesh +{ + public class Vector2FComparer : IEqualityComparer + { + private static Vector2FComparer _instance; + public static Vector2FComparer Default => _instance ?? (_instance = new Vector2FComparer(0.0001f)); + + private readonly float _tolerance; + + public Vector2FComparer(float tolerance) + { + _tolerance = tolerance; + } + + public bool Equals(Vector2F x, Vector2F y) + { + return (int)(x.x / _tolerance) == (int)(y.x / _tolerance) + && (int)(x.y / _tolerance) == (int)(y.y / _tolerance); + } + + public int GetHashCode(Vector2F obj) + { + return (int)(obj.x / _tolerance) ^ ((int)(obj.y / _tolerance) << 2); + } + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Base/Vector3.cs b/LightlessSync/ThirdParty/Nanomesh/Base/Vector3.cs new file mode 100644 index 0000000..96f79f9 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Base/Vector3.cs @@ -0,0 +1,191 @@ +using System; + +namespace Nanomesh +{ + public readonly struct Vector3 : IEquatable, IInterpolable + { + public readonly double x; + public readonly double y; + public readonly double z; + + public Vector3(double x, double y, double z) + { + this.x = x; + this.y = y; + this.z = z; + } + + public Vector3(double x, double y) + { + this.x = x; + this.y = y; + z = 0.0; + } + + public double this[int index] + { + get + { + switch (index) + { + case 0: return x; + case 1: return y; + case 2: return z; + default: + throw new IndexOutOfRangeException("Invalid Vector3 index!"); + } + } + } + + public override int GetHashCode() + { + return x.GetHashCode() ^ (y.GetHashCode() << 2) ^ (z.GetHashCode() >> 2); + } + + public override bool Equals(object other) + { + if (!(other is Vector3)) + { + return false; + } + + return Equals((Vector3)other); + } + + public bool Equals(Vector3 other) + { + return x == other.x && y == other.y && z == other.z; + } + + public static Vector3 operator +(in Vector3 a, in Vector3 b) { return new Vector3(a.x + b.x, a.y + b.y, a.z + b.z); } + + public static Vector3 operator -(in Vector3 a, in Vector3 b) { return new Vector3(a.x - b.x, a.y - b.y, a.z - b.z); } + + public static Vector3 operator -(in Vector3 a) { return new Vector3(-a.x, -a.y, -a.z); } + + public static Vector3 operator *(in Vector3 a, double d) { return new Vector3(a.x * d, a.y * d, a.z * d); } + + public static Vector3 operator *(double d, in Vector3 a) { return new Vector3(a.x * d, a.y * d, a.z * d); } + + public static Vector3 operator /(in Vector3 a, double d) { return new Vector3(MathUtils.DivideSafe(a.x, d), MathUtils.DivideSafe(a.y, d), MathUtils.DivideSafe(a.z, d)); } + + public static bool operator ==(in Vector3 lhs, in Vector3 rhs) + { + double diff_x = lhs.x - rhs.x; + double diff_y = lhs.y - rhs.y; + double diff_z = lhs.z - rhs.z; + double sqrmag = diff_x * diff_x + diff_y * diff_y + diff_z * diff_z; + return sqrmag < MathUtils.EpsilonDouble; + } + + public static bool operator !=(in Vector3 lhs, in Vector3 rhs) + { + return !(lhs == rhs); + } + public static Vector3 Cross(in Vector3 lhs, in Vector3 rhs) + { + return new Vector3( + lhs.y * rhs.z - lhs.z * rhs.y, + lhs.z * rhs.x - lhs.x * rhs.z, + lhs.x * rhs.y - lhs.y * rhs.x); + } + + public static implicit operator Vector3F(Vector3 vec) + { + return new Vector3F((float)vec.x, (float)vec.y, (float)vec.z); + } + + public static explicit operator Vector3(Vector3F vec) + { + return new Vector3(vec.x, vec.y, vec.z); + } + + public static double Dot(in Vector3 lhs, in Vector3 rhs) + { + return lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z; + } + + public static Vector3 Normalize(in Vector3 value) + { + double mag = Magnitude(value); + return value / mag; + } + + public Vector3 Normalized => Vector3.Normalize(this); + + public static double Distance(in Vector3 a, in Vector3 b) + { + double diff_x = a.x - b.x; + double diff_y = a.y - b.y; + double diff_z = a.z - b.z; + return Math.Sqrt(diff_x * diff_x + diff_y * diff_y + diff_z * diff_z); + } + + public static double Magnitude(in Vector3 vector) + { + return Math.Sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z); + } + + public static Vector3 ProjectPointOnLine(in Vector3 linePoint, in Vector3 lineVec, in Vector3 point) + { + Vector3 linePointToPoint = point - linePoint; + return linePoint + lineVec * Dot(linePointToPoint, lineVec); + } + + public static double DistancePointLine(in Vector3 point, in Vector3 lineStart, in Vector3 lineEnd) + { + return Magnitude(ProjectPointOnLine(lineStart, (lineEnd - lineStart).Normalized, point) - point); + } + + public double LengthSquared => x * x + y * y + z * z; + + public double Length => Math.Sqrt(x * x + y * y + z * z); + + public static Vector3 Min(in Vector3 lhs, in Vector3 rhs) + { + return new Vector3(Math.Min(lhs.x, rhs.x), Math.Min(lhs.y, rhs.y), Math.Min(lhs.z, rhs.z)); + } + + public static Vector3 Max(in Vector3 lhs, in Vector3 rhs) + { + return new Vector3(Math.Max(lhs.x, rhs.x), Math.Max(lhs.y, rhs.y), Math.Max(lhs.z, rhs.z)); + } + + public static readonly Vector3 zeroVector = new Vector3(0f, 0f, 0f); + public static readonly Vector3 oneVector = new Vector3(1f, 1f, 1f); + public static readonly Vector3 positiveInfinityVector = new Vector3(float.PositiveInfinity, float.PositiveInfinity, float.PositiveInfinity); + public static readonly Vector3 negativeInfinityVector = new Vector3(float.NegativeInfinity, float.NegativeInfinity, float.NegativeInfinity); + + public static Vector3 Zero => zeroVector; + + public static Vector3 One => oneVector; + + public static Vector3 PositiveInfinity => positiveInfinityVector; + + public static Vector3 NegativeInfinity => negativeInfinityVector; + + public static double AngleRadians(in Vector3 from, in Vector3 to) + { + double denominator = Math.Sqrt(from.LengthSquared * to.LengthSquared); + if (denominator < 1e-15F) + { + return 0F; + } + + double dot = MathF.Clamp(Dot(from, to) / denominator, -1.0, 1.0); + return Math.Acos(dot); + } + + public static double AngleDegrees(in Vector3 from, in Vector3 to) + { + return AngleRadians(from, to) / Math.PI * 180d; + } + + public override string ToString() + { + return $"{x}, {y}, {z}"; + } + + public Vector3 Interpolate(Vector3 other, double ratio) => this * ratio + other * (1 - ratio); + } +} diff --git a/LightlessSync/ThirdParty/Nanomesh/Base/Vector3Comparer.cs b/LightlessSync/ThirdParty/Nanomesh/Base/Vector3Comparer.cs new file mode 100644 index 0000000..9dbf2fb --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Base/Vector3Comparer.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace Nanomesh +{ + public class Vector3Comparer : IEqualityComparer + { + private readonly double _tolerance; + + public Vector3Comparer(double tolerance) + { + _tolerance = tolerance; + } + + public bool Equals(Vector3 x, Vector3 y) + { + return (int)(x.x / _tolerance) == (int)(y.x / _tolerance) + && (int)(x.y / _tolerance) == (int)(y.y / _tolerance) + && (int)(x.z / _tolerance) == (int)(y.z / _tolerance); + } + + public int GetHashCode(Vector3 obj) + { + return (int)(obj.x / _tolerance) ^ ((int)(obj.y / _tolerance) << 2) ^ ((int)(obj.z / _tolerance) >> 2); + } + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Base/Vector3F.cs b/LightlessSync/ThirdParty/Nanomesh/Base/Vector3F.cs new file mode 100644 index 0000000..57b92bf --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Base/Vector3F.cs @@ -0,0 +1,172 @@ +using System; + +namespace Nanomesh +{ + public readonly struct Vector3F : IEquatable, IInterpolable + { + public readonly float x; + public readonly float y; + public readonly float z; + + public Vector3F(float x, float y, float z) + { + this.x = x; + this.y = y; + this.z = z; + } + + public Vector3F(float x, float y) + { + this.x = x; + this.y = y; + z = 0F; + } + + public float this[int index] + { + get + { + switch (index) + { + case 0: return x; + case 1: return y; + case 2: return z; + default: + throw new IndexOutOfRangeException("Invalid Vector3F index!"); + } + } + } + + public override int GetHashCode() + { + return Vector3FComparer.Default.GetHashCode(this); + //return x.GetHashCode() ^ (y.GetHashCode() << 2) ^ (z.GetHashCode() >> 2); + } + + public override bool Equals(object other) + { + if (!(other is Vector3F)) + { + return false; + } + + return Equals((Vector3F)other); + } + + public bool Equals(Vector3F other) + { + return Vector3FComparer.Default.Equals(this, other); + //return x == other.x && y == other.y && z == other.z; + } + + public static Vector3F operator +(in Vector3F a, in Vector3F b) { return new Vector3F(a.x + b.x, a.y + b.y, a.z + b.z); } + + public static Vector3F operator -(in Vector3F a, in Vector3F b) { return new Vector3F(a.x - b.x, a.y - b.y, a.z - b.z); } + + public static Vector3F operator -(in Vector3F a) { return new Vector3F(-a.x, -a.y, -a.z); } + + public static Vector3F operator *(in Vector3F a, float d) { return new Vector3F(a.x * d, a.y * d, a.z * d); } + + public static Vector3F operator *(float d, in Vector3F a) { return new Vector3F(a.x * d, a.y * d, a.z * d); } + + public static Vector3 operator *(double d, in Vector3F a) { return new Vector3(a.x * d, a.y * d, a.z * d); } + + public static Vector3F operator /(in Vector3F a, float d) { return new Vector3F(MathUtils.DivideSafe(a.x, d), MathUtils.DivideSafe(a.y, d), MathUtils.DivideSafe(a.z, d)); } + + public static bool operator ==(in Vector3F lhs, in Vector3F rhs) + { + float diff_x = lhs.x - rhs.x; + float diff_y = lhs.y - rhs.y; + float diff_z = lhs.z - rhs.z; + float sqrmag = diff_x * diff_x + diff_y * diff_y + diff_z * diff_z; + return sqrmag < MathUtils.EpsilonFloat; + } + + public static bool operator !=(in Vector3F lhs, in Vector3F rhs) + { + return !(lhs == rhs); + } + public static Vector3F Cross(in Vector3F lhs, in Vector3F rhs) + { + return new Vector3F( + lhs.y * rhs.z - lhs.z * rhs.y, + lhs.z * rhs.x - lhs.x * rhs.z, + lhs.x * rhs.y - lhs.y * rhs.x); + } + + public static float Dot(in Vector3F lhs, in Vector3F rhs) + { + return lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z; + } + + public static Vector3F Normalize(in Vector3F value) + { + float mag = Magnitude(value); + return value / mag; + } + + public Vector3F Normalized => Vector3F.Normalize(this); + + public static float Distance(in Vector3F a, in Vector3F b) + { + float diff_x = a.x - b.x; + float diff_y = a.y - b.y; + float diff_z = a.z - b.z; + return MathF.Sqrt(diff_x * diff_x + diff_y * diff_y + diff_z * diff_z); + } + + public static float Magnitude(in Vector3F vector) + { + return MathF.Sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z); + } + + public float SqrMagnitude => x * x + y * y + z * z; + + public static Vector3F Min(in Vector3F lhs, in Vector3F rhs) + { + return new Vector3F(MathF.Min(lhs.x, rhs.x), MathF.Min(lhs.y, rhs.y), MathF.Min(lhs.z, rhs.z)); + } + + public static Vector3F Max(in Vector3F lhs, in Vector3F rhs) + { + return new Vector3F(MathF.Max(lhs.x, rhs.x), MathF.Max(lhs.y, rhs.y), MathF.Max(lhs.z, rhs.z)); + } + + public static readonly Vector3F zeroVector = new Vector3F(0f, 0f, 0f); + public static readonly Vector3F oneVector = new Vector3F(1f, 1f, 1f); + public static readonly Vector3F positiveInfinityVector = new Vector3F(float.PositiveInfinity, float.PositiveInfinity, float.PositiveInfinity); + public static readonly Vector3F negativeInfinityVector = new Vector3F(float.NegativeInfinity, float.NegativeInfinity, float.NegativeInfinity); + + public static Vector3F Zero => zeroVector; + + public static Vector3F One => oneVector; + + public static Vector3F PositiveInfinity => positiveInfinityVector; + + public static Vector3F NegativeInfinity => negativeInfinityVector; + + public static float AngleRadians(in Vector3F from, in Vector3F to) + { + float denominator = MathF.Sqrt(from.SqrMagnitude * to.SqrMagnitude); + if (denominator < 1e-15F) + { + return 0F; + } + + float dot = MathF.Clamp(Dot(from, to) / denominator, -1F, 1F); + return MathF.Acos(dot); + } + + public static float AngleDegrees(in Vector3F from, in Vector3F to) + { + return AngleRadians(from, to) / MathF.PI * 180f; + } + + public override string ToString() + { + return $"{x}, {y}, {z}"; + } + + public Vector3F Interpolate(Vector3F other, double ratio) => (ratio * this + (1 - ratio) * other).Normalized; + } +} diff --git a/LightlessSync/ThirdParty/Nanomesh/Base/Vector3FComparer.cs b/LightlessSync/ThirdParty/Nanomesh/Base/Vector3FComparer.cs new file mode 100644 index 0000000..b0fc5fc --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Base/Vector3FComparer.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace Nanomesh +{ + public class Vector3FComparer : IEqualityComparer + { + private static Vector3FComparer _instance; + public static Vector3FComparer Default => _instance ?? (_instance = new Vector3FComparer(0.001f)); + + private readonly float _tolerance; + + public Vector3FComparer(float tolerance) + { + _tolerance = tolerance; + } + + public bool Equals(Vector3F x, Vector3F y) + { + return (int)(x.x / _tolerance) == (int)(y.x / _tolerance) + && (int)(x.y / _tolerance) == (int)(y.y / _tolerance) + && (int)(x.z / _tolerance) == (int)(y.z / _tolerance); + } + + public int GetHashCode(Vector3F obj) + { + return (int)(obj.x / _tolerance) ^ ((int)(obj.y / _tolerance) << 2) ^ ((int)(obj.z / _tolerance) >> 2); + } + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Base/Vector4F.cs b/LightlessSync/ThirdParty/Nanomesh/Base/Vector4F.cs new file mode 100644 index 0000000..c93b65e --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Base/Vector4F.cs @@ -0,0 +1,91 @@ +using System; + +namespace Nanomesh +{ + public readonly struct Vector4F : IEquatable, IInterpolable + { + public readonly float x; + public readonly float y; + public readonly float z; + public readonly float w; + + public Vector4F(float x, float y, float z, float w) + { + this.x = x; + this.y = y; + this.z = z; + this.w = w; + } + + public float this[int index] + { + get + { + switch (index) + { + case 0: return x; + case 1: return y; + case 2: return z; + case 3: return w; + default: + throw new IndexOutOfRangeException("Invalid Vector4F index!"); + } + } + } + + public override int GetHashCode() + { + return Vector4FComparer.Default.GetHashCode(this); + } + + public override bool Equals(object other) + { + if (!(other is Vector4F)) + { + return false; + } + + return Equals((Vector4F)other); + } + + public bool Equals(Vector4F other) + { + return Vector4FComparer.Default.Equals(this, other); + } + + public static Vector4F operator +(in Vector4F a, in Vector4F b) + => new(a.x + b.x, a.y + b.y, a.z + b.z, a.w + b.w); + + public static Vector4F operator -(in Vector4F a, in Vector4F b) + => new(a.x - b.x, a.y - b.y, a.z - b.z, a.w - b.w); + + public static Vector4F operator *(in Vector4F a, float d) + => new(a.x * d, a.y * d, a.z * d, a.w * d); + + public static Vector4F operator *(float d, in Vector4F a) + => new(a.x * d, a.y * d, a.z * d, a.w * d); + + public static Vector4F operator /(in Vector4F a, float d) + => new(MathUtils.DivideSafe(a.x, d), MathUtils.DivideSafe(a.y, d), MathUtils.DivideSafe(a.z, d), MathUtils.DivideSafe(a.w, d)); + + public static bool operator ==(in Vector4F lhs, in Vector4F rhs) + => Vector4FComparer.Default.Equals(lhs, rhs); + + public static bool operator !=(in Vector4F lhs, in Vector4F rhs) + => !Vector4FComparer.Default.Equals(lhs, rhs); + + public static float Dot(in Vector4F lhs, in Vector4F rhs) + => (lhs.x * rhs.x) + (lhs.y * rhs.y) + (lhs.z * rhs.z) + (lhs.w * rhs.w); + + public Vector4F Interpolate(Vector4F other, double ratio) + { + var t = (float)ratio; + var inv = 1f - t; + return new Vector4F( + (x * inv) + (other.x * t), + (y * inv) + (other.y * t), + (z * inv) + (other.z * t), + (w * inv) + (other.w * t)); + } + } +} diff --git a/LightlessSync/ThirdParty/Nanomesh/Base/Vector4FComparer.cs b/LightlessSync/ThirdParty/Nanomesh/Base/Vector4FComparer.cs new file mode 100644 index 0000000..7bc348d --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Base/Vector4FComparer.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; + +namespace Nanomesh +{ + public class Vector4FComparer : IEqualityComparer + { + private static Vector4FComparer? _instance; + public static Vector4FComparer Default => _instance ??= new Vector4FComparer(0.0001f); + + private readonly float _tolerance; + + public Vector4FComparer(float tolerance) + { + _tolerance = tolerance; + } + + public bool Equals(Vector4F x, Vector4F y) + { + return (int)(x.x / _tolerance) == (int)(y.x / _tolerance) + && (int)(x.y / _tolerance) == (int)(y.y / _tolerance) + && (int)(x.z / _tolerance) == (int)(y.z / _tolerance) + && (int)(x.w / _tolerance) == (int)(y.w / _tolerance); + } + + public int GetHashCode(Vector4F obj) + { + return (int)(obj.x / _tolerance) + ^ ((int)(obj.y / _tolerance) << 2) + ^ ((int)(obj.z / _tolerance) >> 2) + ^ ((int)(obj.w / _tolerance) << 1); + } + } +} diff --git a/LightlessSync/ThirdParty/Nanomesh/Base/VertexData.cs b/LightlessSync/ThirdParty/Nanomesh/Base/VertexData.cs new file mode 100644 index 0000000..9dade3e --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Base/VertexData.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; + +namespace Nanomesh +{ + public struct VertexData : IEquatable + { + public int position; + public List attributes; // TODO : This is not optimal regarding memory + + public VertexData(int pos) + { + position = pos; + attributes = new List(); + } + + public override int GetHashCode() + { + unchecked + { + int hash = 17; + hash = hash * 31 + position; + foreach (object attr in attributes) + { + hash = hash * 31 + attr.GetHashCode(); + } + return hash; + } + } + + public bool Equals(VertexData other) + { + if (!position.Equals(other.position)) + return false; + + if (attributes.Count != other.attributes.Count) + return false; + + for (int i = 0; i < attributes.Count; i++) + { + if (!attributes[i].Equals(other.attributes[i])) + return false; + } + + return true; + } + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Collections/CollectionUtils.cs b/LightlessSync/ThirdParty/Nanomesh/Collections/CollectionUtils.cs new file mode 100644 index 0000000..ed754bc --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Collections/CollectionUtils.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; + +namespace Nanomesh +{ + public static class CollectionUtils + { + public static T[] ToArray(this HashSet items, ref T[] array) + { + int i = 0; + foreach (T item in items) + { + array[i++] = item; + } + + return array; + } + + public static bool TryAdd(this Dictionary dictionary, K key, V value) + { + if (dictionary.ContainsKey(key)) + { + return false; + } + + dictionary.Add(key, value); + return true; + } + + public static bool TryAdd(this Dictionary dictionary, K key, Func valueFactory) + { + if (dictionary.ContainsKey(key)) + { + return false; + } + + dictionary.Add(key, valueFactory(key)); + return true; + } + + public static V GetOrAdd(this Dictionary dictionary, K key, V value) + { + if (dictionary.TryGetValue(key, out V existingValue)) + { + return existingValue; + } + + dictionary.Add(key, value); + return value; + } + } +} diff --git a/LightlessSync/ThirdParty/Nanomesh/Collections/FastHashSet.cs b/LightlessSync/ThirdParty/Nanomesh/Collections/FastHashSet.cs new file mode 100644 index 0000000..60d4f12 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Collections/FastHashSet.cs @@ -0,0 +1,3872 @@ +//#define Exclude_Check_For_Set_Modifications_In_Enumerator +//#define Exclude_Check_For_Is_Disposed_In_Enumerator +//#define Exclude_No_Hash_Array_Implementation +//#define Exclude_Cache_Optimize_Resize + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace Nanomesh +{ + // didn't implement ISerializable and IDeserializationCallback + // these are implemented in the .NET HashSet + // the 7th HashSet constructor has params for serialization -implement that if serialization is implemented + // also add using System.Runtime.Serialization; + + public class FastHashSet : ICollection, IEnumerable, IEnumerable, IReadOnlyCollection, ISet + { + private const int MaxSlotsArraySize = int.MaxValue - 2; + + // this is the size of the non-hash array used to make small counts of items faster + private const int InitialArraySize = 8; + + // this is the # of initial nodes for the slots array after going into hashing after using the noHashArray + // this is 16 + 1; the + 1 is for the first node (node at index 0) which doesn't get used because 0 is the NullIndex + private const int InitialSlotsArraySize = 17; + + // this indicates end of chain if the nextIndex of a node has this value and also indicates no chain if a buckets array element has this value + private const int NullIndex = 0; + + // if a node's nextIndex = this value, then it is a blank node - this isn't a valid nextIndex when unmarked and also when marked (because we don't allow int.MaxValue items) + private const int BlankNextIndexIndicator = int.MaxValue; + + // use this instead of the negate negative logic when getting hashindex - this saves an if (hashindex < 0) which can be the source of bad branch prediction + private const int HighBitNotSet = unchecked(0b0111_1111_1111_1111_1111_1111_1111_1111); + + // The Mark... constants below are for marking, unmarking, and checking if an item is marked. + // This is usefull for some set operations. + + // doing an | (bitwise or) with this and the nextIndex marks the node, setting the bit back will give the original nextIndex value + private const int MarkNextIndexBitMask = unchecked((int)0b1000_0000_0000_0000_0000_0000_0000_0000); + + // doing an & (bitwise and) with this and the nextIndex sets it back to the original value (unmarks it) + private const int MarkNextIndexBitMaskInverted = ~MarkNextIndexBitMask; + + // FastHashSet doesn't allow using an item/node index as high as int.MaxValue. + // There are 2 reasons for this: The first is that int.MaxValue is used as a special indicator + private const int LargestPrimeLessThanMaxInt = 2147483629; + + // these are primes above the .75 loadfactor of the power of 2 except from 30,000 through 80,000, where we conserve space to help with cache space + private static readonly int[] bucketsSizeArray = { 11, 23, 47, 89, 173, 347, 691, 1367, 2741, 5471, 10_937, 19_841/*16_411/*21_851*/, 40_241/*32_771/*43_711*/, 84_463/*65_537/*87_383*/, /*131_101*/174_767, + /*262_147*/349_529, 699_053, 1_398_107, 2_796_221, 5_592_407, 11_184_829, 22_369_661, 44_739_259, 89_478_503, 17_8956_983, 35_7913_951, 715_827_947, 143_1655_777, LargestPrimeLessThanMaxInt}; + + // the buckets array can be pre-allocated to a large size, but it's not good to use that entire size for hashing because of cache locality + // instead do at most 3 size steps (for 3 levels of cache) before using its actual allocated size + + // when an initial capacity is selected in the constructor or later, allocate the required space for the buckets array, but only use a subset of this space until the load factor is met + // limit the # of used elements to optimize for cpu caches + private static readonly int[] bucketsSizeArrayForCacheOptimization = { 3_371, 62_851, 701_819 }; + + private const double LoadFactorConst = .75; + + private int currentIndexIntoBucketsSizeArray; + + private int bucketsModSize; + +#if !Exclude_Check_For_Set_Modifications_In_Enumerator + private int incrementForEverySetModification; +#endif + + // resize the buckets array when the count reaches this value + private int resizeBucketsCountThreshold; + + private int count; + + private int nextBlankIndex; + + // this is needed because if items are removed, they get added into the blank list starting at nextBlankIndex, but we may want to TrimExcess capacity, so this is a quick way to see what the ExcessCapacity is + private int firstBlankAtEndIndex; + + private readonly IEqualityComparer comparer; + + // make the buckets size a primary number to make the mod function less predictable + private int[] buckets; + + private TNode[] slots; + +#if !Exclude_No_Hash_Array_Implementation + // used for small sets - when the count of items is small, it is usually faster to just use an array of the items and not do hashing at all (this can also use slightly less memory) + // There may be some cases where the sets can be very small, but there can be very many of these sets. This can be good for these cases. + private T[] noHashArray; +#endif + + internal enum FoundType + { + FoundFirstTime, + FoundNotFirstTime, + NotFound + } + + internal struct TNode + { + // the cached hash code of the item - this is so we don't have to call GetHashCode multiple times, also doubles as a nextIndex for blanks, since blank nodes don't need a hash code + public int hashOrNextIndexForBlanks; + + public int nextIndex; + + public T item; + + public TNode(T elem, int nextIndex, int hash) + { + item = elem; + + this.nextIndex = nextIndex; + + hashOrNextIndexForBlanks = hash; + } + } + + // 1 - same constructor params as HashSet + /// Initializes a new instance of the FastHashSet<>. + /// The element type of the FastHashSet. + public FastHashSet() + { + comparer = EqualityComparer.Default; + SetInitialCapacity(InitialArraySize); + } + + // 2 - same constructor params as HashSet + /// Initializes a new instance of the FastHashSet<>. + /// The element type of the FastHashSet. + /// The collection to initially add to the FastHashSet. + public FastHashSet(IEnumerable collection) + { + comparer = EqualityComparer.Default; + AddInitialEnumerable(collection); + } + + // 3 - same constructor params as HashSet + /// Initializes a new instance of the FastHashSet<>. + /// The element type of the FastHashSet. + /// The IEqualityComparer to use for determining equality of elements in the FastHashSet. + public FastHashSet(IEqualityComparer comparer) + { + this.comparer = comparer ?? EqualityComparer.Default; + SetInitialCapacity(InitialArraySize); + } + + // 4 - same constructor params as HashSet + /// Initializes a new instance of the FastHashSet<>. + /// The element type of the FastHashSet. + /// The initial capacity of the FastHashSet. + public FastHashSet(int capacity) + { + comparer = EqualityComparer.Default; + SetInitialCapacity(capacity); + } + + // 5 - same constructor params as HashSet + /// Initializes a new instance of the FastHashSet<>. + /// The element type of the FastHashSet + /// The collection to initially add to the FastHashSet. + /// The IEqualityComparer to use for determining equality of elements in the FastHashSet. + public FastHashSet(IEnumerable collection, IEqualityComparer comparer) + { + this.comparer = comparer ?? EqualityComparer.Default; + AddInitialEnumerable(collection); + } + + // 6 - same constructor params as HashSet + /// Initializes a new instance of the FastHashSet<>. + /// The element type of the set + /// The initial capacity of the FastHashSet. + /// The IEqualityComparer to use for determining equality of elements in the FastHashSet. + public FastHashSet(int capacity, IEqualityComparer comparer) + { + this.comparer = comparer ?? EqualityComparer.Default; + SetInitialCapacity(capacity); + } + + /// Initializes a new instance of the FastHashSet<>. + /// The element type of the FastHashSet + /// The collection to initially add to the FastHashSet. + /// True if the collection items are all unique. The collection items can be added more quickly if they are known to be unique. + /// The initial capacity of the FastHashSet. + /// The IEqualityComparer to use for determining equality of elements in the FastHashSet. +#if false // removed for now because it's probably not that useful and needs some changes to be correct + public FastHashSet(IEnumerable collection, bool areAllCollectionItemsDefinitelyUnique, int capacity, IEqualityComparer comparer = null) + { + this.comparer = comparer ?? EqualityComparer.Default; + SetInitialCapacity(capacity); + + if (areAllCollectionItemsDefinitelyUnique) + { + // this and the call below must deal correctly with an initial capacity already set + AddInitialUniqueValuesEnumerable(collection); + } + else + { + AddInitialEnumerable(collection); + } + } +#endif + + private void AddInitialUniqueValuesEnumerable(IEnumerable collection) + { + int itemsCount = 0; +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + nextBlankIndex = 1; + foreach (T item in collection) + { + int hash = (comparer.GetHashCode(item) & HighBitNotSet); + int hashIndex = hash % bucketsModSize; + + int index = buckets[hashIndex]; + buckets[hashIndex] = nextBlankIndex; + + ref TNode t = ref slots[nextBlankIndex]; + + t.hashOrNextIndexForBlanks = hash; + t.nextIndex = index; + t.item = item; + + nextBlankIndex++; + itemsCount++; + } +#if !Exclude_No_Hash_Array_Implementation + } + else + { + foreach (T item in collection) + { + noHashArray[itemsCount++] = item; + } + } +#endif + count = itemsCount; + firstBlankAtEndIndex = nextBlankIndex; + } + + private void AddInitialEnumerableWithEnoughCapacity(IEnumerable collection) + { + // this assumes we are hashing + foreach (T item in collection) + { + int hash = (comparer.GetHashCode(item) & HighBitNotSet); + int hashIndex = hash % bucketsModSize; + + for (int index = buckets[hashIndex]; index != NullIndex;) + { + ref TNode t = ref slots[index]; + + if (t.hashOrNextIndexForBlanks == hash && comparer.Equals(t.item, item)) + { + goto Found; // item was found + } + + index = t.nextIndex; + } + + ref TNode tBlank = ref slots[nextBlankIndex]; + + tBlank.hashOrNextIndexForBlanks = hash; + tBlank.nextIndex = buckets[hashIndex]; + tBlank.item = item; + + buckets[hashIndex] = nextBlankIndex; + + nextBlankIndex++; + +#if !Exclude_Cache_Optimize_Resize + count++; + + if (count >= resizeBucketsCountThreshold) + { + ResizeBucketsArrayForward(GetNewBucketsArraySize()); + } +#endif + Found:; + } + firstBlankAtEndIndex = nextBlankIndex; +#if Exclude_Cache_Optimize_Resize + count = nextBlankIndex - 1; +#endif + } + + private void AddInitialEnumerable(IEnumerable collection) + { + FastHashSet fhset = collection as FastHashSet; + if (fhset != null && Equals(fhset.Comparer, Comparer)) + { + // a set with the same item comparer must have all items unique + // so Count will be the exact Count of the items added + // also don't have to check for equals of items + // and a FastHashSet has the additional advantage of not having to call GetHashCode() if it is hashing + // and it has access to the internal slots array so we don't have to use the foreach/enumerator + + int count = fhset.Count; + SetInitialCapacity(count); + +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { + if (fhset.IsHashing) + { +#endif + // this FastHashSet is hashing and collection is a FastHashSet (with equal comparer) and it is also hashing + + nextBlankIndex = 1; + int maxNodeIndex = fhset.slots.Length - 1; + if (fhset.firstBlankAtEndIndex <= maxNodeIndex) + { + maxNodeIndex = fhset.firstBlankAtEndIndex - 1; + } + + for (int i = 1; i <= maxNodeIndex; i++) + { + ref TNode t2 = ref fhset.slots[i]; + if (t2.nextIndex != BlankNextIndexIndicator) + { + int hash = t2.hashOrNextIndexForBlanks; + int hashIndex = hash % bucketsModSize; + + ref TNode t = ref slots[nextBlankIndex]; + + t.hashOrNextIndexForBlanks = hash; + t.nextIndex = buckets[hashIndex]; + t.item = t2.item; + + buckets[hashIndex] = nextBlankIndex; + + nextBlankIndex++; + } + } + this.count = count; + firstBlankAtEndIndex = nextBlankIndex; +#if !Exclude_No_Hash_Array_Implementation + } + else + { + // this FastHashSet is hashing and collection is a FastHashSet (with equal comparer) and it is NOT hashing + + nextBlankIndex = 1; + for (int i = 0; i < fhset.count; i++) + { + ref T item = ref noHashArray[i]; + + int hash = (comparer.GetHashCode(item) & HighBitNotSet); + int hashIndex = hash % bucketsModSize; + + ref TNode t = ref slots[nextBlankIndex]; + + t.hashOrNextIndexForBlanks = hash; + t.nextIndex = buckets[hashIndex]; + t.item = item; + + buckets[hashIndex] = nextBlankIndex; + + nextBlankIndex++; + } + } + } + else + { + // this FastHashSet is not hashing + + AddInitialUniqueValuesEnumerable(collection); + } +#endif + } + else + { + // collection is not a FastHashSet with equal comparer + + HashSet hset = collection as HashSet; + if (hset != null && Equals(hset.Comparer, Comparer)) + { + // a set with the same item comparer must have all items unique + // so Count will be the exact Count of the items added + // also don't have to check for equals of items + + int usedCount = hset.Count; + SetInitialCapacity(usedCount); + + AddInitialUniqueValuesEnumerable(collection); + } + else + { + ICollection coll = collection as ICollection; + if (coll != null) + { + SetInitialCapacity(coll.Count); +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + // call SetInitialCapacity and then set the capacity back to get rid of the excess? + + AddInitialEnumerableWithEnoughCapacity(collection); + + TrimExcess(); +#if !Exclude_No_Hash_Array_Implementation + } + else + { + foreach (T item in collection) + { + Add(item); + } + } +#endif + } + else + { + SetInitialCapacity(InitialArraySize); + + foreach (T item in collection) + { + Add(in item); + } + } + } + } + } + + private void SetInitialCapacity(int capacity) + { +#if !Exclude_No_Hash_Array_Implementation + if (capacity > InitialArraySize) + { +#endif + // skip using the array and go right into hashing + InitHashing(capacity); +#if !Exclude_No_Hash_Array_Implementation + } + else + { + CreateNoHashArray(); // don't set the capacity/size of the noHashArray + } +#endif + } + +#if !Exclude_No_Hash_Array_Implementation + // this function can be called to switch from using the noHashArray and start using the hashing arrays (slots and buckets) + // this function can also be called before noHashArray is even allocated in order to skip using the array and go right into hashing + private void SwitchToHashing(int capacityIncrease = -1) + { + InitHashing(capacityIncrease); + + if (noHashArray != null) + { + // i is the index into noHashArray + for (int i = 0; i < count; i++) + { + ref T item = ref noHashArray[i]; + + int hash = (comparer.GetHashCode(item) & HighBitNotSet); + int hashIndex = hash % bucketsModSize; + + ref TNode t = ref slots[nextBlankIndex]; + + t.hashOrNextIndexForBlanks = hash; + t.nextIndex = buckets[hashIndex]; + t.item = item; + + buckets[hashIndex] = nextBlankIndex; + + nextBlankIndex++; + } + noHashArray = null; // this array can now be garbage collected because it is no longer referenced + } + + firstBlankAtEndIndex = nextBlankIndex; + } +#endif + + private void InitHashing(int capacity = -1) + { + int newSlotsArraySize; + int newBucketsArraySize; + int newBucketsArrayModSize; + + bool setThresh = false; + if (capacity == -1) + { + newSlotsArraySize = InitialSlotsArraySize; + + newBucketsArraySize = bucketsSizeArray[0]; + if (newBucketsArraySize < newSlotsArraySize) + { + for (currentIndexIntoBucketsSizeArray = 1; currentIndexIntoBucketsSizeArray < bucketsSizeArray.Length; currentIndexIntoBucketsSizeArray++) + { + newBucketsArraySize = bucketsSizeArray[currentIndexIntoBucketsSizeArray]; + if (newBucketsArraySize >= newSlotsArraySize) + { + break; + } + } + } + newBucketsArrayModSize = newBucketsArraySize; + } + else + { + newSlotsArraySize = capacity + 1; // add 1 to accomodate blank first node (node at 0 index) + + newBucketsArraySize = FastHashSetUtil.GetEqualOrClosestHigherPrime((int)(newSlotsArraySize / LoadFactorConst)); + +#if !Exclude_Cache_Optimize_Resize + if (newBucketsArraySize > bucketsSizeArrayForCacheOptimization[0]) + { + newBucketsArrayModSize = bucketsSizeArrayForCacheOptimization[0]; + setThresh = true; + } + else +#endif + { + newBucketsArrayModSize = newBucketsArraySize; + } + } + + if (newSlotsArraySize == 0) + { + // this is an error, the int.MaxValue has been used for capacity and we require more - throw an Exception for this + // could try this with HashSet and see what exception it throws? + throw new InvalidOperationException("Exceeded maximum number of items allowed for this container."); + } + + slots = new TNode[newSlotsArraySize]; // the slots array has an extra item as it's first item (0 index) that is for available items - the memory is wasted, but it simplifies things + buckets = new int[newBucketsArraySize]; // these will be initially set to 0, so make 0 the blank(available) value and reduce all indices by one to get to the actual index into the slots array + bucketsModSize = newBucketsArrayModSize; + + if (setThresh) + { + resizeBucketsCountThreshold = (int)(newBucketsArrayModSize * LoadFactorConst); + } + else + { + CalcUsedItemsLoadFactorThreshold(); + } + + nextBlankIndex = 1; // start at 1 because 0 is the blank item + + firstBlankAtEndIndex = nextBlankIndex; + } + +#if !Exclude_No_Hash_Array_Implementation + private void CreateNoHashArray() + { + noHashArray = new T[InitialArraySize]; + } +#endif + + private void CalcUsedItemsLoadFactorThreshold() + { + if (buckets != null) + { + if (buckets.Length == bucketsModSize) + { + resizeBucketsCountThreshold = slots.Length; // with this value, the buckets array should always resize after the slots array (in the same public function call) + } + else + { + // when buckets.Length > bucketsModSize, this means we want to more slowly increase the bucketsModSize to keep things in the L1-3 caches + resizeBucketsCountThreshold = (int)(bucketsModSize * LoadFactorConst); + } + } + } + + /// True if the FastHashSet if read-only. This is always false. This is only present to implement ICollection, it has no real value otherwise. + bool ICollection.IsReadOnly => false; + + /// Copies all elements of the FastHashSet<> into an array starting at arrayIndex. This implements ICollection.CopyTo(T[], Int32). + /// The destination array. + /// The starting array index to copy elements to. + public void CopyTo(T[] array, int arrayIndex) + { + CopyTo(array, arrayIndex, count); + } + + /// Copies all elements of the FastHashSet<> into an array starting at the first array index. + /// The destination array. + public void CopyTo(T[] array) + { + CopyTo(array, 0, count); + } + + // not really sure how this can be useful because you never know exactly what elements you will get copied (unless you copy them all) + // it could easily vary for different implementations or if items were added in different order or if items were added removed and then added, instead of just added + /// Copies count number of elements of the FastHashSet<> into an array starting at arrayIndex. + /// The destination array. + /// The starting array index to copy elements to. + /// The number of elements to copy. + public void CopyTo(T[] array, int arrayIndex, int count) + { + if (array == null) + { + throw new ArgumentNullException(nameof(array), "Value cannot be null."); + } + + if (arrayIndex < 0) + { + throw new ArgumentOutOfRangeException(nameof(arrayIndex), "Non negative number is required."); + } + + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count), "Non negative number is required."); + } + + if (arrayIndex + count > array.Length) + { + throw new ArgumentException("Destination array is not long enough to copy all the items in the collection. Check array index and length."); + } + + if (count == 0) + { + return; + } + +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + int pastNodeIndex = slots.Length; + if (firstBlankAtEndIndex < pastNodeIndex) + { + pastNodeIndex = firstBlankAtEndIndex; + } + + int cnt = 0; + for (int i = 1; i < pastNodeIndex; i++) + { + if (slots[i].nextIndex != BlankNextIndexIndicator) + { + array[arrayIndex++] = slots[i].item; + if (++cnt == count) + { + break; + } + } + } +#if !Exclude_No_Hash_Array_Implementation + } + else + { + int cnt = this.count; + if (cnt > count) + { + cnt = count; + } + + // for small arrays, I think the for loop below will actually be faster than Array.Copy because of the overhead of that function - could test this + //Array.Copy(noHashArray, 0, array, arrayIndex, cnt); + + for (int i = 0; i < cnt; i++) + { + array[arrayIndex++] = noHashArray[i]; + } + } +#endif + } + + /// + /// Gets the IEqualityComparer used to determine equality for items of this FastHashSet. + /// + public IEqualityComparer Comparer => + // if not set, return the default - this is what HashSet does + // even if it is set to null explicitly, it will still return the default + // this behavior is implmented in the constructor + comparer; + + /// + /// >Gets the number of items in this FastHashSet. + /// + public int Count => count; + + // this is the percent of used items to all items (used + blank/available) + // at which point any additional added items will + // first resize the buckets array to the next prime to avoid too many collisions and chains becoming too large + /// + /// Gets the fraction of 'used items count' divided by 'used items plus available/blank items count'. + /// The buckets array is resized when adding items and this fraction is reached, so this is the minimum LoadFactor for the buckets array. + /// + public double LoadFactor => LoadFactorConst; + + // this is the capacity that can be trimmed with TrimExcessCapacity + // items that were removed from the hash arrays can't be trimmed by calling TrimExcessCapacity, only the blank items at the end + // items that were removed from the noHashArray can be trimmed by calling TrimExcessCapacity because the items after are moved to fill the blank space + /// + /// Gets the capacity that can be trimmed with TrimExcessCapacity. + /// + public int ExcessCapacity + { + get + { + int excessCapacity; +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + excessCapacity = slots.Length - firstBlankAtEndIndex; +#if !Exclude_No_Hash_Array_Implementation + } + else + { + excessCapacity = noHashArray.Length - count; + } +#endif + return excessCapacity; + } + } + + /// + /// Gets the capacity of the FastHashSet, which is the number of elements that can be contained without resizing. + /// + public int Capacity + { + get + { +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + return slots.Length - 1; // subtract 1 for blank node at 0 index +#if !Exclude_No_Hash_Array_Implementation + } + else + { + return noHashArray.Length; + } +#endif + } + } + + /// + /// Gets the size of the next capacity increase of the FastHashSet. + /// + public int NextCapacityIncreaseSize => GetNewSlotsArraySizeIncrease(out int oldSlotsArraySize); + + /// + /// Gets the count of items when the next capacity increase (resize) of the FastHashSet will happen. + /// + public int NextCapacityIncreaseAtCount => resizeBucketsCountThreshold; + + public bool IsHashing => noHashArray == null; + + // the actual capacity at the end of this function may be more than specified + // (in the case when it was more before this function was called - nothing is trimmed by this function, or in the case that slighly more capacity was allocated by this function) + /// + /// Allocate enough space (or make sure existing space is enough) for capacity number of items to be stored in the FastHashSet without any further allocations. + /// + /// The capacity to ensure. + /// The actual capacity at the end of this function. + public int EnsureCapacity(int capacity) + { + // this function is only in .net core for HashSet as of 4/15/2019 +#if !Exclude_Check_For_Set_Modifications_In_Enumerator + incrementForEverySetModification++; +#endif + + int currentCapacity; + +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + currentCapacity = slots.Length - count; +#if !Exclude_No_Hash_Array_Implementation + } + else + { + currentCapacity = noHashArray.Length - count; + } +#endif + + if (currentCapacity < capacity) + { + IncreaseCapacity(capacity - currentCapacity); + } + + // this should be the number where the next lowest number would force a resize of buckets array with the current loadfactor and the entire slots array is full + int calcedNewBucketsArraySize = (int)(slots.Length / LoadFactorConst) + 1; + + if (calcedNewBucketsArraySize < 0 && calcedNewBucketsArraySize > LargestPrimeLessThanMaxInt) + { + calcedNewBucketsArraySize = LargestPrimeLessThanMaxInt; + } + else + { + calcedNewBucketsArraySize = FastHashSetUtil.GetEqualOrClosestHigherPrime(calcedNewBucketsArraySize); + } + + if (buckets.Length < calcedNewBucketsArraySize) + { + // -1 means stop trying to increase the size based on the array of primes + // instead calc based on 2 * existing length and then get the next higher prime + currentIndexIntoBucketsSizeArray = -1; + + ResizeBucketsArrayForward(calcedNewBucketsArraySize); + } + + return slots.Length - count; + } + + // return true if bucketsModSize was set, false otherwise + private bool CheckForModSizeIncrease() + { + if (bucketsModSize < buckets.Length) + { + // instead of array, just have 3 constants + int partLength = (int)(buckets.Length * .75); + + int size0 = bucketsSizeArrayForCacheOptimization[0]; + int size1 = bucketsSizeArrayForCacheOptimization[1]; + if (bucketsModSize == size0) + { + if (size1 <= partLength) + { + bucketsModSize = size1; + return true; + } + else + { + bucketsModSize = buckets.Length; + return true; + } + } + else + { + int size2 = bucketsSizeArrayForCacheOptimization[2]; + if (bucketsModSize == size1) + { + if (size2 <= partLength) + { + bucketsModSize = size2; + return true; + } + else + { + bucketsModSize = buckets.Length; + return true; + } + } + else if (bucketsModSize == size2) + { + bucketsModSize = buckets.Length; + return true; + } + } + } + return false; + } + + private int GetNewSlotsArraySizeIncrease(out int oldArraySize) + { + if (slots != null) + { + oldArraySize = slots.Length; + } + else + { + oldArraySize = InitialSlotsArraySize; // this isn't the old array size, but it is the initial size we should start at + } + + int increaseInSize; + + if (oldArraySize == 1) + { + increaseInSize = InitialSlotsArraySize - 1; + } + else + { + increaseInSize = oldArraySize - 1; + } + + int maxIncreaseInSize = MaxSlotsArraySize - oldArraySize; + + if (increaseInSize > maxIncreaseInSize) + { + increaseInSize = maxIncreaseInSize; + } + return increaseInSize; + } + + // if the value returned gets used and that value is different than the current buckets.Length, then the calling code should increment currentIndexIntoSizeArray because this would now be the current + private int GetNewBucketsArraySize() + { + int newArraySize; + + if (currentIndexIntoBucketsSizeArray >= 0) + { + if (currentIndexIntoBucketsSizeArray + 1 < bucketsSizeArray.Length) + { + newArraySize = bucketsSizeArray[currentIndexIntoBucketsSizeArray + 1]; + } + else + { + newArraySize = buckets.Length; + } + } + else + { + // -1 means stop trying to increase the size based on the array of primes + // instead calc based on 2 * existing length and then get the next higher prime + newArraySize = buckets.Length; + if (newArraySize < int.MaxValue / 2) + { + newArraySize = FastHashSetUtil.GetEqualOrClosestHigherPrime(newArraySize + newArraySize); + } + else + { + newArraySize = LargestPrimeLessThanMaxInt; + } + } + + return newArraySize; + } + + // if hashing, increase the size of the slots array + // if not yet hashing, switch to hashing + private void IncreaseCapacity(int capacityIncrease = -1) + { + // this function might be a fair bit over overhead for resizing at small sizes (like 33 and 65) + // could try to reduce the overhead - there could just be a nextSlotsArraySize (don't need increase?), or nextSlotsArraySizeIncrease? + // then we don't have to call GetNewSlotsArraySizeIncrease at all? + // could test the overhead by just replacing all of the code with +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + int newSlotsArraySizeIncrease; + int oldSlotsArraySize; + + if (capacityIncrease == -1) + { + newSlotsArraySizeIncrease = GetNewSlotsArraySizeIncrease(out oldSlotsArraySize); + } + else + { + newSlotsArraySizeIncrease = capacityIncrease; + oldSlotsArraySize = slots.Length; + } + + if (newSlotsArraySizeIncrease <= 0) + { + throw new InvalidOperationException("Exceeded maximum number of items allowed for this container."); + } + + int newSlotsArraySize = oldSlotsArraySize + newSlotsArraySizeIncrease; + + TNode[] newSlotsArray = new TNode[newSlotsArraySize]; + Array.Copy(slots, 0, newSlotsArray, 0, slots.Length); // check the IL, I think Array.Resize and Array.Copy without the start param calls this, so avoid the overhead by calling directly + slots = newSlotsArray; + +#if !Exclude_No_Hash_Array_Implementation + } + else + { + SwitchToHashing(capacityIncrease); + } +#endif + } + + private TNode[] IncreaseCapacityNoCopy(int capacityIncrease = -1) + { +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + int newSlotsrraySizeIncrease; + int oldSlotsArraySize; + + if (capacityIncrease == -1) + { + newSlotsrraySizeIncrease = GetNewSlotsArraySizeIncrease(out oldSlotsArraySize); + } + else + { + newSlotsrraySizeIncrease = capacityIncrease; + oldSlotsArraySize = slots.Length; + } + + if (newSlotsrraySizeIncrease <= 0) + { + throw new InvalidOperationException("Exceeded maximum number of items allowed for this container."); + } + + int newSlotsArraySize = oldSlotsArraySize + newSlotsrraySizeIncrease; + + TNode[] newSlotsArray = new TNode[newSlotsArraySize]; + return newSlotsArray; +#if !Exclude_No_Hash_Array_Implementation + } + else + { + SwitchToHashing(capacityIncrease); + return null; + } +#endif + } + + private void ResizeBucketsArrayForward(int newBucketsArraySize) + { + if (newBucketsArraySize == buckets.Length) + { + // this will still work if no increase in size - it just might be slower than if you could increase the buckets array size + } + else + { + if (!CheckForModSizeIncrease()) //??? clean this up, it isn't really good to do it this way - no need to call GetNewBucketsArraySize before calling this function + { + buckets = new int[newBucketsArraySize]; + bucketsModSize = newBucketsArraySize; + + if (currentIndexIntoBucketsSizeArray >= 0) + { + currentIndexIntoBucketsSizeArray++; // when the newBucketsArraySize gets used in the above code, point to the next avaialble size - ??? not sure this is the best place to increment this + } + } + else + { + Array.Clear(buckets, 0, bucketsModSize); + } + + CalcUsedItemsLoadFactorThreshold(); + + int bucketsArrayLength = buckets.Length; + + int pastNodeIndex = slots.Length; + if (firstBlankAtEndIndex < pastNodeIndex) + { + pastNodeIndex = firstBlankAtEndIndex; + } + + //??? for a loop where the end is array.Length, the compiler can skip any array bounds checking - can it do it for this code - it should be able to because pastIndex is no more than buckets.Length + if (firstBlankAtEndIndex == count + 1) + { + // this means there aren't any blank nodes + for (int i = 1; i < pastNodeIndex; i++) + { + ref TNode t = ref slots[i]; + + int hashIndex = t.hashOrNextIndexForBlanks % bucketsArrayLength; + t.nextIndex = buckets[hashIndex]; + + buckets[hashIndex] = i; + } + } + else + { + // this means there are some blank nodes + for (int i = 1; i < pastNodeIndex; i++) + { + ref TNode t = ref slots[i]; + if (t.nextIndex != BlankNextIndexIndicator) // skip blank nodes + { + int hashIndex = t.hashOrNextIndexForBlanks % bucketsArrayLength; + t.nextIndex = buckets[hashIndex]; + + buckets[hashIndex] = i; + } + } + } + } + } + + private void ResizeBucketsArrayForwardKeepMarks(int newBucketsArraySize) + { + if (newBucketsArraySize == buckets.Length) + { + // this will still work if no increase in size - it just might be slower than if you could increase the buckets array size + } + else + { + //??? what if there is a high percent of blank/unused items in the slots array before the firstBlankAtEndIndex (mabye because of lots of removes)? + // It would probably be faster to loop through the buckets array and then do chaining to find the used nodes - one problem with this is that you would have to find blank nodes - but they would be chained + // this probably isn't a very likely scenario + + if (!CheckForModSizeIncrease()) //??? clean this up, it isn't really good to do it this way - no need to call GetNewBucketsArraySize before calling this function + { + buckets = new int[newBucketsArraySize]; + bucketsModSize = newBucketsArraySize; + + if (currentIndexIntoBucketsSizeArray >= 0) + { + currentIndexIntoBucketsSizeArray++; // when the newBucketsArraySize gets used in the above code, point to the next avaialble size - ??? not sure this is the best place to increment this + } + } + + CalcUsedItemsLoadFactorThreshold(); + + int bucketsArrayLength = buckets.Length; + + int pastNodeIndex = slots.Length; + if (firstBlankAtEndIndex < pastNodeIndex) + { + pastNodeIndex = firstBlankAtEndIndex; + } + + //??? for a loop where the end is array.Length, the compiler can skip any array bounds checking - can it do it for this code - it should be able to because pastIndex is no more than buckets.Length + if (firstBlankAtEndIndex == count + 1) + { + // this means there aren't any blank nodes + for (int i = 1; i < pastNodeIndex; i++) + { + ref TNode t = ref slots[i]; + + int hashIndex = t.hashOrNextIndexForBlanks % bucketsArrayLength; + t.nextIndex = buckets[hashIndex] | (t.nextIndex & MarkNextIndexBitMask); + + buckets[hashIndex] = i; + } + } + else + { + // this means there are some blank nodes + for (int i = 1; i < pastNodeIndex; i++) + { + ref TNode t = ref slots[i]; + if (t.nextIndex != BlankNextIndexIndicator) // skip blank nodes + { + int hashIndex = t.hashOrNextIndexForBlanks % bucketsArrayLength; + t.nextIndex = buckets[hashIndex] | (t.nextIndex & MarkNextIndexBitMask); + + buckets[hashIndex] = i; + } + } + } + } + } + + /// + /// Removes all items from the FastHashSet, but does not do any trimming of the resulting unused memory. + /// To trim the unused memory, call TrimExcess. + /// + public void Clear() + { +#if !Exclude_Check_For_Set_Modifications_In_Enumerator + incrementForEverySetModification++; +#endif + +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) +#endif + { + firstBlankAtEndIndex = 1; + nextBlankIndex = 1; + Array.Clear(buckets, 0, buckets.Length); + } + + count = 0; + } + + // documentation states: + // You can use the TrimExcess method to minimize a HashSet object's memory overhead once it is known that no new elements will be added + // To completely clear a HashSet object and release all memory referenced by it, call this method after calling the Clear method. + /// + /// Trims excess capacity to minimize the FastHashSet's memory overhead. + /// + public void TrimExcess() + { +#if !Exclude_Check_For_Set_Modifications_In_Enumerator + incrementForEverySetModification++; +#endif + +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + if (slots.Length > firstBlankAtEndIndex && firstBlankAtEndIndex > 0) + { + Array.Resize(ref slots, firstBlankAtEndIndex); + // when firstBlankAtEndIndex == slots.Length, that means there are no blank at end items + } +#if !Exclude_No_Hash_Array_Implementation + } + else + { + if (noHashArray != null && noHashArray.Length > count && count > 0) + { + Array.Resize(ref noHashArray, count); + } + } +#endif + } + + // this is only present to implement ICollection - it has no real value otherwise because the Add method with bool return value already does this + /// + /// Implements the ICollection<T> Add method. If possible, use the FastHashSet Add method instead to avoid any slight overhead and return a bool that indicates if the item was added. + /// + /// The item to add. + void ICollection.Add(T item) + { + Add(in item); + } + + // we need 2 versions of Add, one with 'in' and one without 'in' because the one without 'in' is needed to implement the ISet Add method + // always keep the code for these 2 Add methods exactly the same + /// + /// Add an item to the FastHashSet using a read-only reference (in) parameter. Use this version of the Add method when item is a large value type to avoid copying large objects. + /// + /// The item to add. + /// True if the item was added, or false if the FastHashSet already contains the item. + public bool Add(in T item) + { +#if !Exclude_Check_For_Set_Modifications_In_Enumerator + incrementForEverySetModification++; +#endif + +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + + int hash = (comparer.GetHashCode(item) & HighBitNotSet); + int hashIndex = hash % bucketsModSize; + + for (int index = buckets[hashIndex]; index != NullIndex;) + { + ref TNode t = ref slots[index]; + + if (t.hashOrNextIndexForBlanks == hash && comparer.Equals(t.item, item)) + { + return false; // item was found, so return false to indicate it was not added + } + + index = t.nextIndex; + } + + if (nextBlankIndex >= slots.Length) + { + // there aren't any more blank nodes to add items, so we need to increase capacity + IncreaseCapacity(); + } + + int firstIndex = buckets[hashIndex]; + buckets[hashIndex] = nextBlankIndex; + + ref TNode tBlank = ref slots[nextBlankIndex]; + if (nextBlankIndex >= firstBlankAtEndIndex) + { + // the blank nodes starting at firstBlankAtEndIndex aren't chained + nextBlankIndex = ++firstBlankAtEndIndex; + } + else + { + // the blank nodes before firstBlankAtEndIndex are chained (the hashOrNextIndexForBlanks points to the next blank node) + nextBlankIndex = tBlank.hashOrNextIndexForBlanks; + } + + tBlank.hashOrNextIndexForBlanks = hash; + tBlank.nextIndex = firstIndex; + tBlank.item = item; + + count++; + + if (count >= resizeBucketsCountThreshold) + { + ResizeBucketsArrayForward(GetNewBucketsArraySize()); + } + + return true; +#if !Exclude_No_Hash_Array_Implementation + } + else + { + int i; + for (i = 0; i < count; i++) + { + if (comparer.Equals(item, noHashArray[i])) + { + return false; + } + } + + if (i == noHashArray.Length) + { + SwitchToHashing(); + + int hash = (comparer.GetHashCode(item) & HighBitNotSet); + int hashIndex = hash % bucketsModSize; + + ref TNode tBlank = ref slots[nextBlankIndex]; + + tBlank.hashOrNextIndexForBlanks = hash; + tBlank.nextIndex = buckets[hashIndex]; + tBlank.item = item; + + buckets[hashIndex] = nextBlankIndex; + + nextBlankIndex = ++firstBlankAtEndIndex; + + count++; + + return true; + } + else + { + // add to noHashArray + noHashArray[i] = item; + count++; + return true; + } + } +#endif + } + + /// + /// Add an item to the FastHashSet. + /// + /// The item to add. + /// True if the item was added, or false if the FastHashSet already contains the item. + public bool Add(T item) + { +#if !Exclude_Check_For_Set_Modifications_In_Enumerator + incrementForEverySetModification++; +#endif + +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + + int hash = (comparer.GetHashCode(item) & HighBitNotSet); + int hashIndex = hash % bucketsModSize; + + for (int index = buckets[hashIndex]; index != NullIndex;) + { + ref TNode t = ref slots[index]; + + if (t.hashOrNextIndexForBlanks == hash && comparer.Equals(t.item, item)) + { + return false; // item was found, so return false to indicate it was not added + } + + index = t.nextIndex; + } + + if (nextBlankIndex >= slots.Length) + { + // there aren't any more blank nodes to add items, so we need to increase capacity + IncreaseCapacity(); + } + + int firstIndex = buckets[hashIndex]; + buckets[hashIndex] = nextBlankIndex; + + ref TNode tBlank = ref slots[nextBlankIndex]; + if (nextBlankIndex >= firstBlankAtEndIndex) + { + // the blank nodes starting at firstBlankAtEndIndex aren't chained + nextBlankIndex = ++firstBlankAtEndIndex; + } + else + { + // the blank nodes before firstBlankAtEndIndex are chained (the hashOrNextIndexForBlanks points to the next blank node) + nextBlankIndex = tBlank.hashOrNextIndexForBlanks; + } + + tBlank.hashOrNextIndexForBlanks = hash; + tBlank.nextIndex = firstIndex; + tBlank.item = item; + + count++; + + if (count >= resizeBucketsCountThreshold) + { + ResizeBucketsArrayForward(GetNewBucketsArraySize()); + } + + return true; +#if !Exclude_No_Hash_Array_Implementation + } + else + { + int i; + for (i = 0; i < count; i++) + { + if (comparer.Equals(item, noHashArray[i])) + { + return false; + } + } + + if (i == noHashArray.Length) + { + SwitchToHashing(); + + int hash = (comparer.GetHashCode(item) & HighBitNotSet); + int hashIndex = hash % bucketsModSize; + + ref TNode tBlank = ref slots[nextBlankIndex]; + + tBlank.hashOrNextIndexForBlanks = hash; + tBlank.nextIndex = buckets[hashIndex]; + tBlank.item = item; + + buckets[hashIndex] = nextBlankIndex; + + nextBlankIndex = ++firstBlankAtEndIndex; + + count++; + + return true; + } + else + { + // add to noHashArray + noHashArray[i] = item; + count++; + return true; + } + } +#endif + } + + // return the index in the slots array of the item that was added or found + private int AddToHashSetIfNotFound(in T item, int hash, out bool isFound) + { + // this assmes we are hashing + + int hashIndex = hash % bucketsModSize; + + for (int index = buckets[hashIndex]; index != NullIndex;) + { + ref TNode t = ref slots[index]; + + if (t.hashOrNextIndexForBlanks == hash && comparer.Equals(t.item, item)) + { + isFound = true; + return index; // item was found, so return the index of the found item + } + + index = t.nextIndex; + } + + if (nextBlankIndex >= slots.Length) + { + // there aren't any more blank nodes to add items, so we need to increase capacity + IncreaseCapacity(); + ResizeBucketsArrayForward(GetNewBucketsArraySize()); + + // fix things messed up by buckets array resize + hashIndex = hash % bucketsModSize; + } + + int firstIndex = buckets[hashIndex]; + buckets[hashIndex] = nextBlankIndex; + + int addedNodeIndex = nextBlankIndex; + ref TNode tBlank = ref slots[nextBlankIndex]; + if (nextBlankIndex >= firstBlankAtEndIndex) + { + // the blank nodes starting at firstBlankAtEndIndex aren't chained + nextBlankIndex = ++firstBlankAtEndIndex; + } + else + { + // the blank nodes before firstBlankAtEndIndex are chained (the hashOrNextIndexForBlanks points to the next blank node) + nextBlankIndex = tBlank.hashOrNextIndexForBlanks; + } + + tBlank.hashOrNextIndexForBlanks = hash; + tBlank.nextIndex = firstIndex; + tBlank.item = item; + + count++; + + isFound = false; + return addedNodeIndex; // item was not found, so return the index of the added item + } + + // return the node index that was added, or NullIndex if item was found + private int AddToHashSetIfNotFoundAndMark(in T item, int hash) + { + // this assumes we are hashing + + int hashIndex = hash % bucketsModSize; + + for (int index = buckets[hashIndex]; index != NullIndex;) + { + ref TNode t = ref slots[index]; + + if (t.hashOrNextIndexForBlanks == hash && comparer.Equals(t.item, item)) + { + return NullIndex; // item was found, so return NullIndex to indicate it was not added + } + + index = t.nextIndex & MarkNextIndexBitMaskInverted; + } + + if (nextBlankIndex >= slots.Length) + { + // there aren't any more blank nodes to add items, so we need to increase capacity + IncreaseCapacity(); + ResizeBucketsArrayForwardKeepMarks(GetNewBucketsArraySize()); + + // fix things messed up by buckets array resize + hashIndex = hash % bucketsModSize; + } + + int firstIndex = buckets[hashIndex]; + buckets[hashIndex] = nextBlankIndex; + + int addedNodeIndex = nextBlankIndex; + ref TNode tBlank = ref slots[nextBlankIndex]; + if (nextBlankIndex >= firstBlankAtEndIndex) + { + // the blank nodes starting at firstBlankAtEndIndex aren't chained + nextBlankIndex = ++firstBlankAtEndIndex; + } + else + { + // the blank nodes before firstBlankAtEndIndex are chained (the hashOrNextIndexForBlanks points to the next blank node) + nextBlankIndex = tBlank.hashOrNextIndexForBlanks; + } + + tBlank.hashOrNextIndexForBlanks = hash; + tBlank.nextIndex = firstIndex | MarkNextIndexBitMask; + tBlank.item = item; + + count++; + + return addedNodeIndex; // item was not found, so return the index of the added item + } + + // we need 2 versions of Contains, one with 'in' and one without 'in' because the one without 'in' is needed to implement the ICollection Contains method + // always keep the code for these 2 Contains methods exactly the same + /// + /// Return true if the item is contained in the FastHashSet, otherwise return false. Use this version of the Contains method when item is a large value type to avoid copying large objects. + /// + /// The item to search for in the FastHashSet. + /// True if found, false if not found. + public bool Contains(in T item) + { +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + int hash = (comparer.GetHashCode(item) & HighBitNotSet); + int hashIndex = hash % bucketsModSize; + + for (int index = buckets[hashIndex]; index != NullIndex;) + { + ref TNode t = ref slots[index]; + + if (t.hashOrNextIndexForBlanks == hash && comparer.Equals(t.item, item)) + { + return true; // item was found, so return true + } + + index = t.nextIndex; + } + return false; +#if !Exclude_No_Hash_Array_Implementation + } + else + { + for (int i = 0; i < count; i++) + { + if (comparer.Equals(item, noHashArray[i])) + { + return true; // item was found, so return true + } + } + return false; + } +#endif + } + + // this implements Contains for ICollection + /// + /// Return true if the item is contained in the FastHashSet, otherwise return false. + /// + /// The item to search for in the FastHashSet. + /// True if found, false if not found. + public bool Contains(T item) + { +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + int hash = (comparer.GetHashCode(item) & HighBitNotSet); + int hashIndex = hash % bucketsModSize; + + for (int index = buckets[hashIndex]; index != NullIndex;) + { + ref TNode t = ref slots[index]; + + if (t.hashOrNextIndexForBlanks == hash && comparer.Equals(t.item, item)) + { + return true; // item was found, so return true + } + + index = t.nextIndex; + } + return false; +#if !Exclude_No_Hash_Array_Implementation + } + else + { + for (int i = 0; i < count; i++) + { + if (comparer.Equals(item, noHashArray[i])) + { + return true; // item was found, so return true + } + } + return false; + } +#endif + } + + /// + /// Removes the item from the FastHashSet if found and returns true if the item was found and removed. + /// + /// The item value to remove. + /// True if the item was removed, or false if the item was not contained in the FastHashSet. + public bool Remove(T item) + { +#if !Exclude_Check_For_Set_Modifications_In_Enumerator + incrementForEverySetModification++; +#endif + +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + int hash = (comparer.GetHashCode(item) & HighBitNotSet); + int hashIndex = hash % bucketsModSize; + + int priorIndex = NullIndex; + + for (int index = buckets[hashIndex]; index != NullIndex;) + { + ref TNode t = ref slots[index]; + + if (t.hashOrNextIndexForBlanks == hash && comparer.Equals(t.item, item)) + { + // item was found, so remove it + + if (priorIndex == NullIndex) + { + buckets[hashIndex] = t.nextIndex; + } + else + { + slots[priorIndex].nextIndex = t.nextIndex; + } + + // add node to blank chain or to the blanks at the end (if possible) + if (index == firstBlankAtEndIndex - 1) + { + if (nextBlankIndex == firstBlankAtEndIndex) + { + nextBlankIndex--; + } + firstBlankAtEndIndex--; + } + else + { + t.hashOrNextIndexForBlanks = nextBlankIndex; + nextBlankIndex = index; + } + + t.nextIndex = BlankNextIndexIndicator; + + count--; + + return true; + } + + priorIndex = index; + + index = t.nextIndex; + } + return false; // item not found +#if !Exclude_No_Hash_Array_Implementation + } + else + { + for (int i = 0; i < count; i++) + { + if (comparer.Equals(item, noHashArray[i])) + { + // remove the item by moving all remaining items to fill over this one - this is probably faster than Array.CopyTo + for (int j = i + 1; j < count; j++, i++) + { + noHashArray[i] = noHashArray[j]; + } + count--; + return true; + } + } + return false; + } +#endif + } + + // this is a new public method not in HashSet + /// + /// Removes the item from the FastHashSet if found and also if the predicate param evaluates to true on the found item. + /// This is useful if there is something about the found item other than its equality value that can be used to determine if it should be removed. + /// + /// The item value to remove. + /// The predicate to evaluate on the found item. + /// True if the item was removed, or false if the item was not removed. + public bool RemoveIf(in T item, Predicate removeIfPredIsTrue) + { + if (removeIfPredIsTrue == null) + { + throw new ArgumentNullException(nameof(removeIfPredIsTrue), "Value cannot be null."); + } + + // the following code is almost the same as the Remove(item) function except that it additionally invokes the removeIfPredIsTrue param to see if the item should be removed + +#if !Exclude_Check_For_Set_Modifications_In_Enumerator + incrementForEverySetModification++; +#endif + +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + int hash = (comparer.GetHashCode(item) & HighBitNotSet); + int hashIndex = hash % bucketsModSize; + + int priorIndex = NullIndex; + + for (int index = buckets[hashIndex]; index != NullIndex;) + { + ref TNode t = ref slots[index]; + + if (t.hashOrNextIndexForBlanks == hash && comparer.Equals(t.item, item)) + { + if (removeIfPredIsTrue.Invoke(t.item)) + { + // item was found and predicate was true, so remove it + + if (priorIndex == NullIndex) + { + buckets[hashIndex] = t.nextIndex; + } + else + { + slots[priorIndex].nextIndex = t.nextIndex; + } + + // add node to blank chain or to the blanks at the end (if possible) + if (index == firstBlankAtEndIndex - 1) + { + if (nextBlankIndex == firstBlankAtEndIndex) + { + nextBlankIndex--; + } + firstBlankAtEndIndex--; + } + else + { + t.hashOrNextIndexForBlanks = nextBlankIndex; + nextBlankIndex = index; + } + + t.nextIndex = BlankNextIndexIndicator; + + count--; + + return true; + } + else + { + return false; + } + } + + priorIndex = index; + + index = t.nextIndex; + } + return false; // item not found +#if !Exclude_No_Hash_Array_Implementation + } + else + { + for (int i = 0; i < count; i++) + { + if (comparer.Equals(item, noHashArray[i])) + { + if (removeIfPredIsTrue.Invoke(noHashArray[i])) + { + // remove the item by moving all remaining items to fill over this one - this is probably faster than Array.CopyTo + for (int j = i + 1; j < count; j++, i++) + { + noHashArray[i] = noHashArray[j]; + } + count--; + return true; + } + else + { + return false; + } + } + } + return false; + } +#endif + } + + // this is a new public method not in HashSet + /// + /// Returns a ref to the element in the FastHashSet if found, or adds the item if not present in the FastHashSet and returns a ref to the added element. + /// The returned element reference should only be changed in ways that does not effect its GetHashCode value. + /// The returned element reference should only be used before any modifications to the FastHashSet (like Add or Remove) which may invalidate it. + /// + /// The item to be added or found. + /// Set to true if the item is found, or false if the added was not found and added. + /// Returns a ref to the found item or to the added item. + public ref T FindOrAdd(in T item, out bool isFound) + { +#if !Exclude_Check_For_Set_Modifications_In_Enumerator + incrementForEverySetModification++; +#endif + + isFound = false; +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + int addedOrFoundItemIndex = AddToHashSetIfNotFound(in item, (comparer.GetHashCode(item) & HighBitNotSet), out isFound); + return ref slots[addedOrFoundItemIndex].item; +#if !Exclude_No_Hash_Array_Implementation + } + else + { + int i; + for (i = 0; i < count; i++) + { + if (comparer.Equals(item, noHashArray[i])) + { + isFound = true; + return ref noHashArray[i]; + } + } + + if (i == noHashArray.Length) + { + SwitchToHashing(); + return ref FindOrAdd(in item, out isFound); + } + else + { + // add to noHashArray and keep isAdded true + noHashArray[i] = item; + count++; + return ref noHashArray[i]; + } + } +#endif + } + + // this is a new public method not in HashSet + /// + /// Tries to find the element with the same value as item in the FastHashSet and, if found, it returns a ref to this found element. + /// This is similar to TryGetValue except it returns a ref to the actual element rather than creating copy of the element with an out parameter. + /// This allows the actual element to be changed if it is a mutable value type. + /// The returned element reference should only be changed in ways that does not effect its GetHashCode value. + /// The returned element reference should only be used before any modifications to the FastHashSet (like Add or Remove) which may invalidate it. + /// + /// The item to be found. + /// Set to true if the item is found, or false if not found. + /// Returns a ref to the element if it is found and sets the isFound out parameter to true. If not found, it returns a ref to the first element available and sets the isFound out parameter to false. + public ref T Find(in T item, out bool isFound) + { + isFound = false; +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + FindInSlotsArray(item, out int foundNodeIndex, out int priorNodeIndex, out int bucketsIndex); + if (foundNodeIndex != NullIndex) + { + isFound = true; + } + + return ref slots[foundNodeIndex].item; +#if !Exclude_No_Hash_Array_Implementation + } + else + { + int i; + for (i = 0; i < count; i++) + { + if (comparer.Equals(item, noHashArray[i])) + { + isFound = true; + return ref noHashArray[i]; + } + } + + // if item was not found, still need to return a ref to something, so return a ref to the first item in the array + return ref noHashArray[0]; + } +#endif + } + + // this is a new public method not in HashSet + /// + /// Tries to find the element with the same value as item in the FastHashSet and, if found,it returns a ref to this found element, except if it is also removed (which is determined by the removeIfPredIsTrue parameter). + /// The returned element reference should only be changed in ways that does not effect its GetHashCode value. + /// The returned element reference should only be used before any modifications to the FastHashSet (like Add or Remove) which may invalidate it. + /// + /// + /// The predicate to evaluate on the found item. + /// Set to true if the item is found, or false if not found. + /// Set to true if the item is found and then removed, or false if not removed. + /// Returns a ref to the element if it is found (and not removed) and sets the isFound out parameter to true and the isRemoved out parameter to false. If removed, it returns a reference to the first available element. + public ref T FindAndRemoveIf(in T item, Predicate removeIfPredIsTrue, out bool isFound, out bool isRemoved) + { + if (removeIfPredIsTrue == null) + { + throw new ArgumentNullException(nameof(removeIfPredIsTrue), "Value cannot be null."); + } + +#if !Exclude_Check_For_Set_Modifications_In_Enumerator + incrementForEverySetModification++; +#endif + + isFound = false; + isRemoved = false; + +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + FindInSlotsArray(item, out int foundNodeIndex, out int priorNodeIndex, out int bucketsIndex); + if (foundNodeIndex != NullIndex) + { + isFound = true; + ref TNode t = ref slots[foundNodeIndex]; + if (removeIfPredIsTrue.Invoke(t.item)) + { + if (priorNodeIndex == NullIndex) + { + buckets[bucketsIndex] = t.nextIndex; + } + else + { + slots[priorNodeIndex].nextIndex = t.nextIndex; + } + + // add node to blank chain or to the blanks at the end (if possible) + if (foundNodeIndex == firstBlankAtEndIndex - 1) + { + if (nextBlankIndex == firstBlankAtEndIndex) + { + nextBlankIndex--; + } + firstBlankAtEndIndex--; + } + else + { + t.hashOrNextIndexForBlanks = nextBlankIndex; + nextBlankIndex = foundNodeIndex; + } + + t.nextIndex = BlankNextIndexIndicator; + + count--; + + isRemoved = true; + + foundNodeIndex = NullIndex; + } + } + + return ref slots[foundNodeIndex].item; +#if !Exclude_No_Hash_Array_Implementation + } + else + { + int i; + for (i = 0; i < count; i++) + { + if (comparer.Equals(item, noHashArray[i])) + { + isFound = true; + if (removeIfPredIsTrue.Invoke(noHashArray[i])) + { + // remove the item by moving all remaining items to fill over this one - this is probably faster than Array.CopyTo + for (int j = i + 1; j < count; j++, i++) + { + noHashArray[i] = noHashArray[j]; + } + count--; + + isRemoved = true; + return ref noHashArray[0]; + } + else + { + return ref noHashArray[i]; + } + } + } + + // if item was not found, still need to return a ref to something, so return a ref to the first item in the array + return ref noHashArray[0]; + } +#endif + } + + // return index into slots array or 0 if not found + //??? to make things faster, could have a FindInSlotsArray that just returns foundNodeIndex and another version called FindWithPriorInSlotsArray that has the 3 out params + // first test to make sure this works as is + private void FindInSlotsArray(in T item, out int foundNodeIndex, out int priorNodeIndex, out int bucketsIndex) + { + foundNodeIndex = NullIndex; + priorNodeIndex = NullIndex; + + int hash = (comparer.GetHashCode(item) & HighBitNotSet); + int hashIndex = hash % bucketsModSize; + + bucketsIndex = hashIndex; + + int priorIndex = NullIndex; + + for (int index = buckets[hashIndex]; index != NullIndex;) + { + ref TNode t = ref slots[index]; + + if (t.hashOrNextIndexForBlanks == hash && comparer.Equals(t.item, item)) + { + foundNodeIndex = index; + priorNodeIndex = priorIndex; + return; // item was found + } + + priorIndex = index; + + index = t.nextIndex; + } + return; // item not found + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool FindInSlotsArray(in T item, int hash) + { + int hashIndex = hash % bucketsModSize; + + for (int index = buckets[hashIndex]; index != NullIndex;) + { + ref TNode t = ref slots[index]; + + if (t.hashOrNextIndexForBlanks == hash && comparer.Equals(t.item, item)) + { + return true; // item was found, so return true + } + + index = t.nextIndex; + } + return false; + } + +#if !Exclude_No_Hash_Array_Implementation + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool FindInNoHashArray(in T item) + { + for (int i = 0; i < count; i++) + { + if (comparer.Equals(item, noHashArray[i])) + { + return true; // item was found, so return true + } + } + return false; + } +#endif + + private void UnmarkAllNextIndexValues(int maxNodeIndex) + { + // must be hashing to be here + for (int i = 1; i <= maxNodeIndex; i++) + { + slots[i].nextIndex &= MarkNextIndexBitMaskInverted; + } + } + + // removeMarked = true, means remove the marked items and keep the unmarked items + // removeMarked = false, means remove the unmarked items and keep the marked items + private void UnmarkAllNextIndexValuesAndRemoveAnyMarkedOrUnmarked(bool removeMarked) + { + // must be hashing to be here + + // must traverse all of the chains instead of just looping through the slots array because going through the chains is the only way to set + // nodes within a chain to blank and still be able to remove the blank node from the chain + + int index; + int nextIndex; + int priorIndex; + int lastNonBlankIndex = firstBlankAtEndIndex - 1; + for (int i = 0; i < buckets.Length; i++) + { + priorIndex = NullIndex; // 0 means use buckets array + index = buckets[i]; + + while (index != NullIndex) + { + ref TNode t = ref slots[index]; + nextIndex = t.nextIndex; + bool isMarked = (nextIndex & MarkNextIndexBitMask) != 0; + if (isMarked) + { + // this node is marked, so unmark it + nextIndex &= MarkNextIndexBitMaskInverted; + t.nextIndex = nextIndex; + } + + if (removeMarked == isMarked) + { + // set this node to blank + + count--; + + // first try to set it to blank by adding it to the blank at end group + if (index == lastNonBlankIndex) + { + //??? does it make sense to attempt this because any already blank items before this will not get added + lastNonBlankIndex--; + if (nextBlankIndex == firstBlankAtEndIndex) + { + nextBlankIndex--; + } + firstBlankAtEndIndex--; + } + else + { + // add to the blank group + + t.nextIndex = BlankNextIndexIndicator; + + t.hashOrNextIndexForBlanks = nextBlankIndex; + nextBlankIndex = index; + } + + if (priorIndex == NullIndex) + { + buckets[i] = nextIndex; + } + else + { + slots[priorIndex].nextIndex = nextIndex; + } + + // keep priorIndex the same because we removed the node in the chain, so the priorIndex is still the same value + } + else + { + priorIndex = index; // node was not removed from the chain, so the priorIndex now points to the node that was not removed + } + + index = nextIndex; + } + } + } + + private FoundType FindInSlotsArrayAndMark(in T item, out int foundNodeIndex) + { + int hash = (comparer.GetHashCode(item) & HighBitNotSet); + int hashIndex = hash % bucketsModSize; + + int index = buckets[hashIndex]; + + if (index == NullIndex) + { + foundNodeIndex = NullIndex; + return FoundType.NotFound; + } + else + { + // item with same hashIndex already exists, so need to look in the chained list for an equal item (using Equals) + + int nextIndex; + while (true) + { + ref TNode t = ref slots[index]; + nextIndex = t.nextIndex; + + // check if hash codes are equal before calling Equals (which may take longer) items that are Equals must have the same hash code + if (t.hashOrNextIndexForBlanks == hash && comparer.Equals(t.item, item)) + { + foundNodeIndex = index; + if ((nextIndex & MarkNextIndexBitMask) == 0) + { + // not marked, so mark it + t.nextIndex |= MarkNextIndexBitMask; + + return FoundType.FoundFirstTime; + } + return FoundType.FoundNotFirstTime; + } + + nextIndex &= MarkNextIndexBitMaskInverted; + if (nextIndex == NullIndex) + { + foundNodeIndex = NullIndex; + return FoundType.NotFound; // not found + } + else + { + index = nextIndex; + } + } + } + } + + // this is a new public method not in HashSet + /// + /// Get the information about the size of chains in the FastHashSet. + /// The size of chains should be small to reduce traversing and comparing items. + /// This can indicate the effectiveness of the hash code creation method. + /// + /// Outputs the average node visits per chain. This is a single number that summarizes the average length of chains in terms of the average number of compares until an equal value is found (when the item is present). + /// A List of LevelAndCount items that gives the length of each chain in the FastHashSet. + public List GetChainLevelsCounts(out double avgNodeVisitsPerChain) + { + Dictionary itemsInChainToCountDict = new Dictionary(); + + // this function only makes sense when hashing + int chainCount = 0; + if (buckets != null) + { + for (int i = 0; i < buckets.Length; i++) + { + int index = buckets[i]; + if (index != NullIndex) + { + chainCount++; + int itemsInChain = 1; + + while (slots[index].nextIndex != NullIndex) + { + index = slots[index].nextIndex; + itemsInChain++; + } + + itemsInChainToCountDict.TryGetValue(itemsInChain, out int cnt); + cnt++; + itemsInChainToCountDict[itemsInChain] = cnt; + } + } + } + + double totalAvgNodeVisitsIfVisitingAllChains = 0; + List lst = new List(itemsInChainToCountDict.Count); + foreach (KeyValuePair keyVal in itemsInChainToCountDict) + { + lst.Add(new ChainLevelAndCount(keyVal.Key, keyVal.Value)); + if (keyVal.Key == 1) + { + totalAvgNodeVisitsIfVisitingAllChains += keyVal.Value; + } + else + { + totalAvgNodeVisitsIfVisitingAllChains += keyVal.Value * (keyVal.Key + 1.0) / 2.0; + } + } + + if (chainCount == 0) + { + avgNodeVisitsPerChain = 0; + } + else + { + avgNodeVisitsPerChain = totalAvgNodeVisitsIfVisitingAllChains / chainCount; + } + + lst.Sort(); + + return lst; + } + + // this is a new public method not in HashSet + /// + /// Reorders items in the same hash chain (items that have the same hash code or mod to the same index), so that they are adjacent in memory. + /// This gives better locality of reference for larger count of items, which can result in fewer cache misses. + /// + public void ReorderChainedNodesToBeAdjacent() + { + if (slots != null) + { + TNode[] newSlotsArray = new TNode[slots.Length]; + + // copy elements using the buckets array chains so there is better locality in the chains + int index; + int newIndex = 1; + for (int i = 0; i < buckets.Length; i++) + { + index = buckets[i]; + if (index != NullIndex) + { + buckets[i] = newIndex; + while (true) + { + ref TNode t = ref slots[index]; + ref TNode tNew = ref newSlotsArray[newIndex]; + index = t.nextIndex; + newIndex++; + + // copy + tNew.hashOrNextIndexForBlanks = t.hashOrNextIndexForBlanks; + tNew.item = t.item; + if (index == NullIndex) + { + tNew.nextIndex = NullIndex; + break; + } + tNew.nextIndex = newIndex; + } + } + } + + newIndex++; + nextBlankIndex = newIndex; + firstBlankAtEndIndex = newIndex; + slots = newSlotsArray; + } + } + + /// + /// Looks for equalValue and if found, returns a copy of the found value in actualValue and returns true. + /// + /// The item to look for. + /// The copy of the found value, if found, or the default value of the same type if not found. + /// True if equalValue is found, or false if not found. + public bool TryGetValue(T equalValue, out T actualValue) + { +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + FindInSlotsArray(equalValue, out int foundNodeIndex, out int priorNodeIndex, out int bucketsIndex); + if (foundNodeIndex > 0) + { + actualValue = slots[foundNodeIndex].item; + return true; + } + + actualValue = default; + return false; +#if !Exclude_No_Hash_Array_Implementation + } + else + { + int i; + for (i = 0; i < count; i++) + { + if (comparer.Equals(equalValue, noHashArray[i])) + { + actualValue = noHashArray[i]; + return true; + } + } + + actualValue = default; + return false; + } +#endif + } + + /// + /// Adds all items in into this FastHashSet. This is similar to AddRange for other types of collections, but it is called UnionWith for ISets. + /// + /// The enumerable items to add (cannot be null). + public void UnionWith(IEnumerable other) + { + if (other == null) + { + throw new ArgumentNullException(nameof(other), "Value cannot be null."); + } + + // Note: HashSet doesn't seem to increment this unless it really changes something - like doing an Add(3) when 3 is already in the hashset doesn't increment, same as doing a UnionWith with an empty set as the param. +#if !Exclude_Check_For_Set_Modifications_In_Enumerator + incrementForEverySetModification++; +#endif + + if (other == this) + { + return; + } + + //??? maybe there is a faster way to add a bunch at one time - I copied the Add code below to make this faster + //foreach (T item in range) + //{ + // Add(item); + //} + + // do this with more code because it might get called in some high performance situations + +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + foreach (T item in other) + { + AddToHashSetIfNotFound(in item, (comparer.GetHashCode(item) & HighBitNotSet), out bool isFound); + } +#if !Exclude_No_Hash_Array_Implementation + } + else + { + int i; + + foreach (T item in other) + { + //??? if it's easier for the jit compiler or il compiler to remove the array bounds checking then + // have i < noHashArray.Length and do the check for count within the loop with a break statement + for (i = 0; i < count; i++) + { + if (comparer.Equals(item, noHashArray[i])) + { + goto found; // break out of inner for loop + } + } + + // if here then item was not found + if (i == noHashArray.Length) + { + SwitchToHashing(); + AddToHashSetIfNotFound(in item, (comparer.GetHashCode(item) & HighBitNotSet), out bool isFound); + } + else + { + // add to noHashArray + noHashArray[i] = item; + count++; + } + + found:; + } + } +#endif + } + + /// + /// Removes all items in from the FastHashSet. + /// + /// The enumerable items (cannot be null). + public void ExceptWith(IEnumerable other) + { + if (other == null) + { + throw new ArgumentNullException(nameof(other), "Value cannot be null."); + } + +#if !Exclude_Check_For_Set_Modifications_In_Enumerator + incrementForEverySetModification++; +#endif + if (other == this) + { + Clear(); + } + else + { + foreach (T item in other) + { + Remove(item); + } + } + } + + /// + /// Removes items from the FastHashSet so that the only remaining items are those contained in that also match an item in the FastHashSet. + /// + /// The enumerable items (cannot be null). + public void IntersectWith(IEnumerable other) + { + if (other == null) + { + throw new ArgumentNullException(nameof(other), "Value cannot be null."); + } + + if (other == this) + { + return; + } + +#if !Exclude_Check_For_Set_Modifications_In_Enumerator + incrementForEverySetModification++; +#endif + + // if hashing, find each item in the slots array and mark anything found, but remove from being found again + +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + int foundItemCount = 0; // the count of found items in the hash - without double counting + foreach (T item in other) + { + FoundType foundType = FindInSlotsArrayAndMark(in item, out int foundIndex); + if (foundType == FoundType.FoundFirstTime) + { + foundItemCount++; + + if (foundItemCount == count) + { + break; + } + } + } + + if (foundItemCount == 0) + { + Clear(); + } + else + { + UnmarkAllNextIndexValuesAndRemoveAnyMarkedOrUnmarked(false); + } +#if !Exclude_No_Hash_Array_Implementation + } + else + { + // Note: we could actually do this faster by moving any found items to the front and keeping track of the found items + // with a single int index + // the problem with this method is it reorders items and even though that shouldn't matter in a set + // it might cause issues with code that incorrectly assumes order stays the same for operations like this + + // possibly a faster implementation would be to use the method above, but keep track of original order with an int array of the size of count (ex. item at 0 was originally 5, and also item at 5 was originally 0) + + // set the corresponding bit in this int if an item was found + // using a uint means the no hashing array cannot be more than 32 items + uint foundItemBits = 0; + + int i; + + int foundItemCount = 0; // the count of found items in the hash - without double counting + foreach (T item in other) + { + for (i = 0; i < count; i++) + { + if (comparer.Equals(item, noHashArray[i])) + { + uint mask = (1u << i); + if ((foundItemBits & mask) == 0) + { + foundItemBits |= mask; + foundItemCount++; + } + goto found; // break out of inner for loop + } + } + + found: + if (foundItemCount == count) + { + // all items in the set were found, so there is nothing to remove - the set isn't changed + return; + } + } + + if (foundItemCount == 0) + { + count = 0; // this is the equivalent of calling Clear + } + else + { + // remove any items that are unmarked (unfound) + // go backwards because this can be faster + for (i = count - 1; i >= 0; i--) + { + uint mask = (1u << i); + if ((foundItemBits & mask) == 0) + { + if (i < count - 1) + { + // a faster method if there are multiple unfound items in a row is to find the first used item (make i go backwards until the item is used and then increment i by 1) + // if there aren't multiple unused in a row, then this is a bit of a waste + + int j = i + 1; // j now points to the next item after the unfound one that we want to keep + + i--; + while (i >= 0) + { + uint mask2 = (1u << i); + if ((foundItemBits & mask2) != 0) + { + break; + } + i--; + } + i++; + + int k = i; + for (; j < count; j++, k++) + { + noHashArray[k] = noHashArray[j]; + } + } + + count--; + } + } + } + } +#endif + } + + // An empty set is a proper subset of any other collection. Therefore, this method returns true if the collection represented by the current HashSet object + // is empty unless the other parameter is also an empty set. + // This method always returns false if Count is greater than or equal to the number of elements in other. + // If the collection represented by other is a HashSet collection with the same equality comparer as the current HashSet object, + // then this method is an O(n) operation. Otherwise, this method is an O(n + m) operation, where n is Count and m is the number of elements in other. + + /// + /// Returns true if this FastHashSet is a proper subset of . + /// + /// The enumerable items (cannot be null). + /// True if a proper subset of . + public bool IsProperSubsetOf(IEnumerable other) + { + if (other == null) + { + throw new ArgumentNullException(nameof(other), "Value cannot be null."); + } + + if (other == this) + { + return false; + } + + ICollection collection = other as ICollection; + if (collection != null) + { + if (count == 0 && collection.Count > 0) + { + return true; // by definition, an empty set is a proper subset of any non-empty collection + } + + if (count >= collection.Count) + { + return false; + } + } + else + { + if (count == 0) + { + foreach (T item in other) + { + return true; + } + return false; + } + } + +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + int foundItemCount = 0; // the count of found items in the hash - without double counting + int maxFoundIndex = 0; + bool notFoundAtLeastOne = false; + foreach (T item in other) + { + FoundType foundType = FindInSlotsArrayAndMark(in item, out int foundIndex); + if (foundType == FoundType.FoundFirstTime) + { + foundItemCount++; + if (maxFoundIndex < foundIndex) + { + maxFoundIndex = foundIndex; + } + } + else if (foundType == FoundType.NotFound) + { + notFoundAtLeastOne = true; + } + + if (notFoundAtLeastOne && foundItemCount == count) + { + // true means all of the items in the set were found in other and at least one item in other was not found in the set + break; // will return true below after unmarking + } + } + + UnmarkAllNextIndexValues(maxFoundIndex); + + return notFoundAtLeastOne && foundItemCount == count; // true if all of the items in the set were found in other and at least one item in other was not found in the set +#if !Exclude_No_Hash_Array_Implementation + } + else + { + uint foundItemBits = 0; + + int foundItemCount = 0; // the count of found items in the hash - without double counting + bool notFoundAtLeastOne = false; + foreach (T item in other) + { + for (int i = 0; i < count; i++) + { + if (comparer.Equals(item, noHashArray[i])) + { + uint mask = (1u << i); + if ((foundItemBits & mask) == 0) + { + foundItemBits |= mask; + foundItemCount++; + } + goto found; // break out of inner for loop + } + } + + // if here then item was not found + notFoundAtLeastOne = true; + + found: + if (notFoundAtLeastOne && foundItemCount == count) + { + // true means all of the items in the set were found in other and at least one item in other was not found in the set + return true; + } + } + + return false; + } +#endif + } + + /// + /// Returns true if this FastHashSet is a subset of . + /// + /// The enumerable items (cannot be null). + /// True if a subset of . + public bool IsSubsetOf(IEnumerable other) + { + if (other == null) + { + throw new ArgumentNullException(nameof(other), "Value cannot be null."); + } + + if (other == this) + { + return true; + } + + if (count == 0) + { + return true; // by definition, an empty set is a subset of any collection + } + + ICollection collection = other as ICollection; + if (collection != null) + { + if (count > collection.Count) + { + return false; + } + } + +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + int foundItemCount = 0; // the count of found items in the hash - without double counting + int maxFoundIndex = 0; + foreach (T item in other) + { + FoundType foundType = FindInSlotsArrayAndMark(in item, out int foundIndex); + if (foundType == FoundType.FoundFirstTime) + { + foundItemCount++; + if (maxFoundIndex < foundIndex) + { + maxFoundIndex = foundIndex; + } + + if (foundItemCount == count) + { + break; + } + } + } + + UnmarkAllNextIndexValues(maxFoundIndex); + + return foundItemCount == count; // true if all of the items in the set were found in other +#if !Exclude_No_Hash_Array_Implementation + } + else + { + uint foundItemBits = 0; + + int foundItemCount = 0; // the count of found items in the hash - without double counting + foreach (T item in other) + { + for (int i = 0; i < count; i++) + { + if (comparer.Equals(item, noHashArray[i])) + { + uint mask = (1u << i); + if ((foundItemBits & mask) == 0) + { + foundItemBits |= mask; + foundItemCount++; + } + goto found; // break out of inner for loop + } + } + + found: + if (foundItemCount == count) + { + break; + } + } + + return foundItemCount == count; // true if all of the items in the set were found in other + } +#endif + } + + /// + /// Returns true if this FastHashSet is a proper superset of . + /// + /// The enumerable items (cannot be null). + /// True if a proper superset of . + public bool IsProperSupersetOf(IEnumerable other) + { + if (other == null) + { + throw new ArgumentNullException(nameof(other), "Value cannot be null."); + } + + if (other == this) + { + return false; + } + + if (count == 0) + { + return false; // an empty set can never be a proper superset of anything (not even an empty collection) + } + + ICollection collection = other as ICollection; + if (collection != null) + { + if (collection.Count == 0) + { + return true; // by definition, an empty other means the set is a proper superset of it if the set has at least one value + } + } + else + { + foreach (T item in other) + { + goto someItemsInOther; + } + return true; + } + + someItemsInOther: + +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + int foundItemCount = 0; // the count of found items in the hash - without double counting + int maxFoundIndex = 0; + foreach (T item in other) + { + FoundType foundType = FindInSlotsArrayAndMark(in item, out int foundIndex); + if (foundType == FoundType.FoundFirstTime) + { + foundItemCount++; + if (maxFoundIndex < foundIndex) + { + maxFoundIndex = foundIndex; + } + + if (foundItemCount == count) + { + break; + } + } + else if (foundType == FoundType.NotFound) + { + // any unfound item means this can't be a proper superset of + UnmarkAllNextIndexValues(maxFoundIndex); + return false; + } + } + + UnmarkAllNextIndexValues(maxFoundIndex); + + return foundItemCount < count; // true if all of the items in other were found in set and at least one item in set was not found in other +#if !Exclude_No_Hash_Array_Implementation + } + else + { + uint foundItemBits = 0; + + int foundItemCount = 0; // the count of found items in the hash - without double counting + foreach (T item in other) + { + for (int i = 0; i < count; i++) + { + if (comparer.Equals(item, noHashArray[i])) + { + uint mask = (1u << i); + if ((foundItemBits & mask) == 0) + { + foundItemBits |= mask; + foundItemCount++; + } + goto found; // break out of inner for loop + } + } + + // if here then item was not found + return false; + + found: + if (foundItemCount == count) + { + break; + } + } + + return foundItemCount < count; // true if all of the items in other were found in set and at least one item in set was not found in other + } +#endif + } + + /// + /// Returns true if this FastHashSet is a superset of . + /// + /// The enumerable items (cannot be null). + /// True if a superset of . + public bool IsSupersetOf(IEnumerable other) + { + if (other == null) + { + throw new ArgumentNullException(nameof(other), "Value cannot be null."); + } + + if (other == this) + { + return true; + } + + ICollection collection = other as ICollection; + if (collection != null) + { + if (collection.Count == 0) + { + return true; // by definition, an empty other means the set is a superset of it + } + } + else + { + foreach (T item in other) + { + goto someItemsInOther; + } + return true; + } + + someItemsInOther: + + if (count == 0) + { + return false; // an empty set can never be a proper superset of anything (except an empty collection - but an empty collection returns true above) + } + +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + foreach (T item in other) + { + if (!FindInSlotsArray(in item, (comparer.GetHashCode(item) & HighBitNotSet))) + { + return false; + } + } + + return true; // true if all of the items in other were found in the set, false if at least one item in other was not found in the set +#if !Exclude_No_Hash_Array_Implementation + } + else + { + int i; + + foreach (T item in other) + { + for (i = 0; i < count; i++) + { + if (comparer.Equals(item, noHashArray[i])) + { + goto found; // break out of inner for loop + } + } + + // if here then item was not found + return false; + + found:; + + } + + return true; // true if all of the items in other were found in the set, false if at least one item in other was not found in the set + } +#endif + } + + /// + /// Returns true if this FastHashSet contains any items in . + /// + /// The enumerable items (cannot be null). + /// True if contains any items in . + public bool Overlaps(IEnumerable other) + { + if (other == null) + { + throw new ArgumentNullException(nameof(other), "Value cannot be null."); + } + + if (other == this) + { + return count > 0; // return false if there are no items when both sets are the same, otherwise return true when both sets are the same + } + + foreach (T item in other) + { + if (Contains(in item)) + { + return true; + } + } + return false; + } + + /// + /// Returns true if this FastHashSet contains exactly the same elements as . + /// + /// The enumerable items (cannot be null). + /// True if contains the same elements as . + public bool SetEquals(IEnumerable other) + { + if (other == null) + { + throw new ArgumentNullException(nameof(other), "Value cannot be null."); + } + + if (other == this) + { + return true; + } + + // if other is ICollection, then it has count + + ICollection c = other as ICollection; + + if (c != null) + { + if (c.Count < count) + { + return false; + } + + HashSet hset = other as HashSet; + if (hset != null && Equals(hset.Comparer, Comparer)) + { + if (hset.Count != count) + { + return false; + } + + foreach (T item in other) + { + if (!Contains(in item)) + { + return false; + } + } + return true; + } + + FastHashSet fhset = other as FastHashSet; + if (fhset != null && Equals(fhset.Comparer, Comparer)) + { + if (fhset.Count != count) + { + return false; + } + +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + int pastNodeIndex = slots.Length; + if (firstBlankAtEndIndex < pastNodeIndex) + { + pastNodeIndex = firstBlankAtEndIndex; + } + +#if !Exclude_No_Hash_Array_Implementation + if (fhset.IsHashing) + { +#endif + for (int i = 1; i < pastNodeIndex; i++) + { + // could not do the blank check if we know there aren't any blanks - below code and in the loop in the else + // could do the check to see if there are any blanks first and then have 2 versions of this code, one with the check for blank and the other without it + if (slots[i].nextIndex != BlankNextIndexIndicator) // skip any blank nodes + { + if (!fhset.FindInSlotsArray(in slots[i].item, slots[i].hashOrNextIndexForBlanks)) + { + return false; + } + } + } +#if !Exclude_No_Hash_Array_Implementation + } + else + { + for (int i = 1; i < pastNodeIndex; i++) + { + if (slots[i].nextIndex != BlankNextIndexIndicator) // skip any blank nodes + { + if (!fhset.FindInNoHashArray(in slots[i].item)) + { + return false; + } + } + } + } + } + else + { + foreach (T item in other) + { + if (!FindInNoHashArray(in item)) + { + return false; + } + } + } + return true; +#endif + } + + } + + +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + int foundItemCount = 0; // the count of found items in the hash - without double counting + int maxFoundIndex = 0; + foreach (T item in other) + { + FoundType foundType = FindInSlotsArrayAndMark(in item, out int foundIndex); + if (foundType == FoundType.FoundFirstTime) + { + foundItemCount++; + if (maxFoundIndex < foundIndex) + { + maxFoundIndex = foundIndex; + } + } + else if (foundType == FoundType.NotFound) + { + UnmarkAllNextIndexValues(maxFoundIndex); + return false; + } + } + + UnmarkAllNextIndexValues(maxFoundIndex); + + return foundItemCount == count; +#if !Exclude_No_Hash_Array_Implementation + } + else + { + uint foundItemBits = 0; + + int foundItemCount = 0; // the count of found items in the hash - without double counting + foreach (T item in other) + { + for (int i = 0; i < count; i++) + { + if (comparer.Equals(item, noHashArray[i])) + { + uint mask = (1u << i); + if ((foundItemBits & mask) == 0) + { + foundItemBits |= mask; + foundItemCount++; + } + goto found; // break out of inner for loop + } + } + // if here then item was not found + return false; + found:; + } + + return foundItemCount == count; + } +#endif + } + + // From the online document: Modifies the current HashSet object to contain only elements that are present either in that object or in the specified collection, but not both. + /// + /// Modifies the FastHashSet so that it contains only items in the FashHashSet or , but not both. + /// So items in that are also in the FastHashSet are removed, and items in that are not in the FastHashSet are added. + /// + /// The enumerable items (cannot be null). + public void SymmetricExceptWith(IEnumerable other) + { + if (other == null) + { + throw new ArgumentNullException(nameof(other), "Value cannot be null."); + } + + if (other == this) + { + Clear(); + } + +#if !Exclude_Check_For_Set_Modifications_In_Enumerator + incrementForEverySetModification++; +#endif + +#if !Exclude_No_Hash_Array_Implementation + if (!IsHashing) + { + // to make things easier for now, just switch to hashing if calling this function and deal with only one set of code + SwitchToHashing(); + } +#endif + + // for the first loop through other, add any unfound items and mark + int addedNodeIndex; + int maxAddedNodeIndex = NullIndex; + foreach (T item in other) + { + addedNodeIndex = AddToHashSetIfNotFoundAndMark(in item, (comparer.GetHashCode(item) & HighBitNotSet)); + if (addedNodeIndex > maxAddedNodeIndex) + { + maxAddedNodeIndex = addedNodeIndex; + } + } + + foreach (T item in other) + { + RemoveIfNotMarked(in item); + } + + UnmarkAllNextIndexValues(maxAddedNodeIndex); + } + + private void RemoveIfNotMarked(in T item) + { + // calling this function assumes we are hashing + int hash = (comparer.GetHashCode(item) & HighBitNotSet); + int hashIndex = hash % bucketsModSize; + + int priorIndex = NullIndex; + + for (int index = buckets[hashIndex]; index != NullIndex;) + { + ref TNode t = ref slots[index]; + + if (t.hashOrNextIndexForBlanks == hash && comparer.Equals(t.item, item)) + { + // item was found, so remove it if not marked + if ((t.nextIndex & MarkNextIndexBitMask) == 0) + { + if (priorIndex == NullIndex) + { + buckets[hashIndex] = t.nextIndex; + } + else + { + // if slots[priorIndex].nextIndex was marked, then keep it marked + // already know that t.nextIndex is not marked + slots[priorIndex].nextIndex = t.nextIndex | (slots[priorIndex].nextIndex & MarkNextIndexBitMask); + } + + // add node to blank chain or to the blanks at the end (if possible) + if (index == firstBlankAtEndIndex - 1) + { + if (nextBlankIndex == firstBlankAtEndIndex) + { + nextBlankIndex--; + } + firstBlankAtEndIndex--; + } + else + { + t.hashOrNextIndexForBlanks = nextBlankIndex; + nextBlankIndex = index; + } + + t.nextIndex = BlankNextIndexIndicator; + + count--; + + return; + } + } + + priorIndex = index; + + index = t.nextIndex & MarkNextIndexBitMaskInverted; + } + return; // item not found + } + + /// + /// Removes any items in the FastHashSet where the predicate is true for that item. + /// + /// The match predicate (cannot be null). + /// The number of items removed. + public int RemoveWhere(Predicate match) + { + if (match == null) + { + throw new ArgumentNullException(nameof(match), "Value cannot be null."); + } + +#if !Exclude_Check_For_Set_Modifications_In_Enumerator + incrementForEverySetModification++; +#endif + + int removeCount = 0; + +#if !Exclude_No_Hash_Array_Implementation + if (IsHashing) + { +#endif + // must traverse all of the chains instead of just looping through the slots array because going through the chains is the only way to set + // nodes within a chain to blank and still be able to remove the blank node from the chain + + int priorIndex; + int nextIndex; + for (int i = 0; i < buckets.Length; i++) + { + priorIndex = NullIndex; // 0 means use buckets array + + for (int index = buckets[i]; index != NullIndex;) + { + ref TNode t = ref slots[index]; + + nextIndex = t.nextIndex; + if (match.Invoke(t.item)) + { + // item was matched, so remove it + + if (priorIndex == NullIndex) + { + buckets[i] = nextIndex; + } + else + { + slots[priorIndex].nextIndex = nextIndex; + } + + // add node to blank chain or to the blanks at the end (if possible) + if (index == firstBlankAtEndIndex - 1) + { + if (nextBlankIndex == firstBlankAtEndIndex) + { + nextBlankIndex--; + } + firstBlankAtEndIndex--; + } + else + { + t.hashOrNextIndexForBlanks = nextBlankIndex; + nextBlankIndex = index; + } + + t.nextIndex = BlankNextIndexIndicator; + + count--; + removeCount++; + } + + priorIndex = index; + + index = nextIndex; + } + } +#if !Exclude_No_Hash_Array_Implementation + } + else + { + int i; + for (i = count - 1; i >= 0; i--) + { + if (match.Invoke(noHashArray[i])) + { + removeCount++; + + if (i < count - 1) + { + int j = i + 1; + int k = i; + for (; j < count; j++, k++) + { + noHashArray[k] = noHashArray[j]; + } + } + + count--; + } + } + } +#endif + + return removeCount; + } + + private class FastHashSetEqualityComparer : IEqualityComparer> + { + public bool Equals(FastHashSet x, FastHashSet y) + { + if (x == null && y == null) + { + return true; + } + + if (y == null) + { + return false; + } + + if (x != null) + { + return x.SetEquals(y); + } + else + { + return false; + } + } + + public int GetHashCode(FastHashSet set) + { + if (set == null) + { + // oddly the documentation for the IEqualityComparer.GetHashCode function says it will throw an ArgumentNullException if the param is null + return 0; // 0 seems to be what .NET framework uses when passing in null, so return the same thing to be consistent + } + else + { + unchecked + { + int hashCode = 0; +#if !Exclude_No_Hash_Array_Implementation + if (set.IsHashing) + { +#endif + int pastNodeIndex = set.slots.Length; + if (set.firstBlankAtEndIndex < pastNodeIndex) + { + pastNodeIndex = set.firstBlankAtEndIndex; + } + + for (int i = 1; i < pastNodeIndex; i++) + { + if (set.slots[i].nextIndex != 0) // nextIndex == 0 indicates a blank/available node + { + // maybe do ^= instead of add? - will this produce the same thing regardless of order? - if ^= maybe we don't need unchecked + // sum up the individual item hash codes - this way it won't matter what order the items are in, the same resulting hash code will be produced + hashCode += set.slots[i].hashOrNextIndexForBlanks; + } + } +#if !Exclude_No_Hash_Array_Implementation + } + else + { + for (int i = 0; i < set.count; i++) + { + // sum up the individual item hash codes - this way it won't matter what order the items are in, the same resulting hash code will be produced + hashCode += set.noHashArray[i].GetHashCode(); + } + } +#endif + return hashCode; + } + } + } + } + + /// + /// Creates and returns the IEqualityComparer for a FastHashSet which can be used to compare two FastHashSets based on their items being equal. + /// + /// An IEqualityComparer for a FastHashSet. + public static IEqualityComparer> CreateSetComparer() + { + return new FastHashSetEqualityComparer(); + } + + /// + /// Allows enumerating through items in the FastHashSet. Order is not guaranteed. + /// + /// The IEnumerator for the FastHashSet. + public IEnumerator GetEnumerator() + { + return new FastHashSetEnumerator(this); + } + + /// + /// Allows enumerating through items in the FastHashSet. Order is not guaranteed. + /// + /// The IEnumerator for the FastHashSet. + IEnumerator IEnumerable.GetEnumerator() + { + return new FastHashSetEnumerator(this); + } + + private class FastHashSetEnumerator : IEnumerator + { + private readonly FastHashSet set; + private int currentIndex = -1; + +#if !Exclude_Check_For_Is_Disposed_In_Enumerator + private bool isDisposed; +#endif + +#if !Exclude_Check_For_Set_Modifications_In_Enumerator + private readonly int incrementForEverySetModification; +#endif + + /// + /// Constructor for the FastHashSetEnumerator that takes a FastHashSet as a parameter. + /// + /// The FastHashSet to enumerate through. + public FastHashSetEnumerator(FastHashSet set) + { + this.set = set; +#if !Exclude_No_Hash_Array_Implementation + if (set.IsHashing) + { +#endif + currentIndex = NullIndex; // 0 is the index before the first possible node (0 is the blank node) +#if !Exclude_No_Hash_Array_Implementation + } + else + { + currentIndex = -1; + } +#endif + +#if !Exclude_Check_For_Set_Modifications_In_Enumerator + incrementForEverySetModification = set.incrementForEverySetModification; +#endif + } + + /// + /// Moves to the next item for the FastHashSet enumerator. + /// + /// True if there was a next item, otherwise false. + public bool MoveNext() + { +#if !Exclude_Check_For_Is_Disposed_In_Enumerator + if (isDisposed) + { + // the only reason this code returns false when Disposed is called is to be compatable with HashSet + // if this level of compatibility isn't needed, then #define Exclude_Check_For_Is_Disposed_In_Enumerator to remove this check and makes the code slightly faster + return false; + } +#endif + +#if !Exclude_Check_For_Set_Modifications_In_Enumerator + if (incrementForEverySetModification != set.incrementForEverySetModification) + { + throw new InvalidOperationException("Collection was modified; enumeration operation may not execute."); + } +#endif + +#if !Exclude_No_Hash_Array_Implementation + if (set.IsHashing) + { +#endif + // it's easiest to just loop through the node array and skip any nodes that are blank + // rather than looping through the buckets array and following the nextIndex to the end of each bucket + + while (true) + { + currentIndex++; + if (currentIndex < set.firstBlankAtEndIndex) + { + if (set.slots[currentIndex].nextIndex != BlankNextIndexIndicator) + { + return true; + } + } + else + { + currentIndex = set.firstBlankAtEndIndex; + return false; + } + } +#if !Exclude_No_Hash_Array_Implementation + } + else + { + currentIndex++; + if (currentIndex < set.count) + { + return true; + } + else + { + currentIndex--; + return false; + } + } +#endif + } + + /// + /// Resets the FastHashSet enumerator. + /// + public void Reset() + { +#if !Exclude_Check_For_Set_Modifications_In_Enumerator + if (incrementForEverySetModification != set.incrementForEverySetModification) + { + throw new InvalidOperationException("Collection was modified; enumeration operation may not execute."); + } +#endif + +#if !Exclude_No_Hash_Array_Implementation + if (set.IsHashing) + { +#endif + currentIndex = NullIndex; // 0 is the index before the first possible node (0 is the blank node) +#if !Exclude_No_Hash_Array_Implementation + } + else + { + currentIndex = -1; + } +#endif + } + + /// + /// Implements the IDisposable.Dispose method for the FastHashSet enumerator. + /// + void IDisposable.Dispose() + { +#if !Exclude_Check_For_Is_Disposed_In_Enumerator + isDisposed = true; +#endif + } + + /// + /// Gets the current item for the FastHashSet enumerator. + /// + public T2 Current + { + get + { +#if !Exclude_No_Hash_Array_Implementation + if (set.IsHashing) + { +#endif + // it's easiest to just loop through the node array and skip any nodes with nextIndex = 0 + // rather than looping through the buckets array and following the nextIndex to the end of each bucket + + if (currentIndex > NullIndex && currentIndex < set.firstBlankAtEndIndex) + { + return set.slots[currentIndex].item; + } +#if !Exclude_No_Hash_Array_Implementation + } + else + { + if (currentIndex >= 0 && currentIndex < set.count) + { + return set.noHashArray[currentIndex]; + } + } +#endif + return default; + } + } + + /// + /// Gets a reference to the current item for the FastHashSet enumerator. + /// + public ref T2 CurrentRef + { + get + { +#if !Exclude_No_Hash_Array_Implementation + if (set.IsHashing) + { +#endif + // it's easiest to just loop through the node array and skip any nodes with nextIndex = 0 + // rather than looping through the buckets array and following the nextIndex to the end of each bucket + + if (currentIndex > NullIndex && currentIndex < set.firstBlankAtEndIndex) + { + return ref set.slots[currentIndex].item; + } + else + { + // we can just return a ref to the 0 node's item instead of throwing an exception? - this should have a default item value + return ref set.slots[0].item; + } +#if !Exclude_No_Hash_Array_Implementation + } + else + { + if (currentIndex >= 0 && currentIndex < set.count) + { + return ref set.noHashArray[currentIndex]; + } + else + { + // we can just return a ref to the 0 node's item instead of throwing an exception? + return ref set.noHashArray[0]; + } + } +#endif + } + } + + /// + /// True if the current item is valid for the FastHashSet enumerator, otherwise false. + /// + public bool IsCurrentValid + { + get + { +#if !Exclude_No_Hash_Array_Implementation + if (set.IsHashing) + { +#endif + // it's easiest to just loop through the node array and skip any nodes with nextIndex = 0 + // rather than looping through the buckets array and following the nextIndex to the end of each bucket + + if (currentIndex > NullIndex && currentIndex < set.firstBlankAtEndIndex) + { + return true; + } +#if !Exclude_No_Hash_Array_Implementation + } + else + { + if (currentIndex >= 0 && currentIndex < set.count) + { + return true; + } + } +#endif + return false; + } + } + + /// + /// Gets the Current item for the FastHashSet enumerator. + /// + object IEnumerator.Current => Current; + } + + public static class FastHashSetUtil + { + /// + /// Return the prime number that is equal to n (if n is a prime number) or the closest prime number greather than n. + /// + /// The lowest number to start looking for a prime. + /// The passed in n parameter value (if it is prime), or the next highest prime greater than n. + public static int GetEqualOrClosestHigherPrime(int n) + { + if (n >= LargestPrimeLessThanMaxInt) + { + // the next prime above this number is int.MaxValue, which we don't want to return that value because some indices increment one or two ints past this number and we don't want them to overflow + return LargestPrimeLessThanMaxInt; + } + + if ((n & 1) == 0) + { + n++; // make n odd + } + + bool found; + + do + { + found = true; + + int sqrt = (int)Math.Sqrt(n); + for (int i = 3; i <= sqrt; i += 2) + { + int div = n / i; + if (div * i == n) // dividing and multiplying might be faster than a single % (n % i) == 0 + { + found = false; + n += 2; + break; + } + } + } while (!found); + + return n; + } + } + } + + public struct ChainLevelAndCount : IComparable + { + public ChainLevelAndCount(int level, int count) + { + Level = level; + Count = count; + } + + public int Level; + public int Count; + + public int CompareTo(ChainLevelAndCount other) + { + return Level.CompareTo(other.Level); + } + } + +#if DEBUG + public static class DebugOutput + { + public static void OutputEnumerableItems(IEnumerable e, string enumerableName) + { + System.Diagnostics.Debug.WriteLine("---start items: " + enumerableName + "---"); + int count = 0; + foreach (T2 item in e) + { + System.Diagnostics.Debug.WriteLine(item.ToString()); + count++; + } + System.Diagnostics.Debug.WriteLine("---end items: " + enumerableName + "; count = " + count.ToString("N0") + "---"); + } + + public static void OutputSortedEnumerableItems(IEnumerable e, string enumerableName) + { + List lst = new List(e); + lst.Sort(); + System.Diagnostics.Debug.WriteLine("---start items (sorted): " + enumerableName + "---"); + int count = 0; + foreach (T2 item in lst) + { + System.Diagnostics.Debug.WriteLine(item.ToString()); + count++; + } + System.Diagnostics.Debug.WriteLine("---end items: " + enumerableName + "; count = " + count.ToString("N0") + "---"); + } + } +#endif +} diff --git a/LightlessSync/ThirdParty/Nanomesh/Collections/LinkedHashSet.cs b/LightlessSync/ThirdParty/Nanomesh/Collections/LinkedHashSet.cs new file mode 100644 index 0000000..a72f4d3 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Collections/LinkedHashSet.cs @@ -0,0 +1,565 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Nanomesh +{ + public class LinkedHashSet : IReadOnlyCollection where T : IComparable + { + private readonly Dictionary> elements; + private LinkedHashNode first, last; + + /// + /// Initializes a new instance of the class. + /// + public LinkedHashSet() + { + elements = new Dictionary>(); + } + + /// + /// Initializes a new instance of the class. + /// + /// + public LinkedHashSet(IEnumerable initialValues) : this() + { + UnionWith(initialValues); + } + + public LinkedHashNode First => first; + + public LinkedHashNode Last => last; + + #region Implementation of IEnumerable + + /// + /// Returns an enumerator that iterates through the collection. + /// + /// + /// A that can be used to iterate through the collection. + /// + /// 1 + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + /// Returns an enumerator that iterates through a collection. + /// + /// + /// An object that can be used to iterate through the collection. + /// + /// 2 + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + #endregion + + #region Implementation of ICollection + + /// + /// Gets the number of elements contained in the . + /// + /// + /// The number of elements contained in the . + /// + public int Count => elements.Count; + + /// + /// Removes all items from the . + /// + /// The is read-only. + public void Clear() + { + elements.Clear(); + first = null; + last = null; + } + + /// + /// Determines whether the contains a specific value. + /// + /// + /// true if is found in the ; otherwise, false. + /// + /// The object to locate in the . + public bool Contains(T item) + { + return elements.ContainsKey(item); + } + + /// + /// Copies the elements of the to an , starting at a particular index. + /// + /// The one-dimensional that is the destination of the elements copied from . The must have zero-based indexing.The zero-based index in at which copying begins. is null. is less than 0. is multidimensional.-or-The number of elements in the source is greater than the available space from to the end of the destination .-or-Type cannot be cast automatically to the type of the destination . + public void CopyTo(T[] array, int arrayIndex) + { + int index = arrayIndex; + + foreach (T item in this) + { + array[index++] = item; + } + } + + /// + /// Removes the first occurrence of a specific object from the . + /// + /// + /// true if was successfully removed from the ; otherwise, false. This method also returns false if is not found in the original . + /// + /// The object to remove from the .The is read-only. + public bool Remove(T item) + { + if (elements.TryGetValue(item, out LinkedHashNode node)) + { + elements.Remove(item); + Unlink(node); + return true; + } + + return false; + } + + #endregion + + + #region Implementation of ISet + + /// + /// Modifies the current set so that it contains all elements that are present in either the current set or the specified collection. + /// + /// The collection to compare to the current set. is null. + public void UnionWith(IEnumerable other) + { + foreach (T item in other) + { + Add(item); + } + } + + /// + /// Modifies the current set so that it contains only elements that are also in a specified collection. + /// + /// The collection to compare to the current set. is null. + public void IntersectWith(IEnumerable other) + { + ISet otherSet = AsSet(other); + + LinkedHashNode current = first; + while (current != null) + { + if (!otherSet.Contains(current.Value)) + { + elements.Remove(current.Value); + Unlink(current); + } + current = current.Next; + } + } + + /// + /// Removes all elements in the specified collection from the current set. + /// + /// The collection of items to remove from the set. is null. + public void ExceptWith(IEnumerable other) + { + foreach (T item in other) + { + Remove(item); + } + } + + /// + /// Modifies the current set so that it contains only elements that are present either in the current set or in the specified collection, but not both. + /// + /// The collection to compare to the current set. is null. + public void SymmetricExceptWith(IEnumerable other) + { + foreach (T item in other) + { + if (elements.TryGetValue(item, out LinkedHashNode node)) + { + elements.Remove(item); + Unlink(node); + } + else + { + Add(item); + } + } + } + + /// + /// Determines whether the current set is a superset of a specified collection. + /// + /// + /// true if the current set is a superset of ; otherwise, false. + /// + /// The collection to compare to the current set. is null. + public bool IsSupersetOf(IEnumerable other) + { + int numberOfOthers = CountOthers(other, out int numberOfOthersPresent); + + // All others must be present. + return numberOfOthersPresent == numberOfOthers; + } + + /// + /// Determines whether the current set is a correct superset of a specified collection. + /// + /// + /// true if the object is a correct superset of ; otherwise, false. + /// + /// The collection to compare to the current set. is null. + public bool IsProperSupersetOf(IEnumerable other) + { + int numberOfOthers = CountOthers(other, out int numberOfOthersPresent); + + // All others must be present, plus we need to have at least one additional item. + return numberOfOthersPresent == numberOfOthers && numberOfOthers < Count; + } + + /// + /// Determines whether the current set and the specified collection contain the same elements. + /// + /// + /// true if the current set is equal to ; otherwise, false. + /// + /// The collection to compare to the current set. is null. + public bool SetEquals(IEnumerable other) + { + int numberOfOthers = CountOthers(other, out int numberOfOthersPresent); + + return numberOfOthers == Count && numberOfOthersPresent == Count; + } + + /// + /// Adds an element to the current set and returns a value to indicate if the element was successfully added. + /// + /// + /// true if the element is added to the set; false if the element is already in the set. + /// + /// The element to add to the set. + public bool Add(T item) + { + if (elements.ContainsKey(item)) + { + return false; + } + + LinkedHashNode node = new LinkedHashNode(item) { Previous = last }; + + if (first == null) + { + first = node; + } + + if (last != null) + { + last.Next = node; + } + + last = node; + + elements.Add(item, node); + + return true; + } + + public bool AddAfter(T item, LinkedHashNode itemInPlace) + { + if (elements.ContainsKey(item)) + { + return false; + } + + LinkedHashNode node = new LinkedHashNode(item) { Previous = itemInPlace }; + + if (itemInPlace.Next != null) + { + node.Next = itemInPlace.Next; + itemInPlace.Next.Previous = node; + } + else + { + last = node; + } + + itemInPlace.Next = node; + + elements.Add(item, node); + + return true; + } + + public bool PushAfter(T item, LinkedHashNode itemInPlace) + { + if (elements.ContainsKey(item)) + { + return false; + } + + LinkedHashNode node = Last; + Unlink(node); + elements.Remove(node.Value); + node.Value = item; + node.Next = null; + node.Previous = itemInPlace; + + if (itemInPlace.Next != null) + { + node.Next = itemInPlace.Next; + itemInPlace.Next.Previous = node; + } + else + { + last = node; + } + + itemInPlace.Next = node; + + elements.Add(item, node); + + return true; + } + + public bool AddBefore(T item, LinkedHashNode itemInPlace) + { + if (elements.ContainsKey(item)) + { + return false; + } + + LinkedHashNode node = new LinkedHashNode(item) { Next = itemInPlace }; + + if (itemInPlace.Previous != null) + { + node.Previous = itemInPlace.Previous; + itemInPlace.Previous.Next = node; + } + else + { + first = node; + } + + itemInPlace.Previous = node; + + elements.Add(item, node); + + return true; + } + + public bool PushBefore(T item, LinkedHashNode itemInPlace) + { + if (elements.ContainsKey(item)) + { + return false; + } + + LinkedHashNode node = Last; + Unlink(node); + elements.Remove(node.Value); + node.Value = item; + node.Previous = null; + node.Next = itemInPlace; + + if (itemInPlace.Previous != null) + { + node.Previous = itemInPlace.Previous; + itemInPlace.Previous.Next = node; + } + else + { + first = node; + } + + itemInPlace.Previous = node; + + elements.Add(item, node); + + return true; + } + + #endregion + + /// + /// Returns an enumerator that iterates through a collection. + /// + /// + /// An struct that can be used to iterate through the collection. + /// + public Enumerator GetEnumerator() + { + return new Enumerator(this); + } + + + /// + /// Count the elements in the given collection and determine both the total + /// count and how many of the elements that are present in the current set. + /// + private int CountOthers(IEnumerable items, out int numberOfOthersPresent) + { + numberOfOthersPresent = 0; + int numberOfOthers = 0; + + foreach (T item in items) + { + numberOfOthers++; + if (Contains(item)) + { + numberOfOthersPresent++; + } + } + return numberOfOthers; + } + + + /// + /// Cast the given collection to an ISet<T> if possible. If not, + /// return a new set containing the items. + /// + private static ISet AsSet(IEnumerable items) + { + return items as ISet ?? new HashSet(items); + } + + + /// + /// Unlink a node from the linked list by updating the node pointers in + /// its preceeding and subsequent node. Also update the _first and _last + /// pointers if necessary. + /// + private void Unlink(LinkedHashNode node) + { + if (node.Previous != null) + { + node.Previous.Next = node.Next; + } + + if (node.Next != null) + { + node.Next.Previous = node.Previous; + } + + if (ReferenceEquals(node, first)) + { + first = node.Next; + } + + if (ReferenceEquals(node, last)) + { + last = node.Previous; + } + } + + public class LinkedHashNode + { + public TElement Value; + public LinkedHashNode Next; + public LinkedHashNode Previous; + + public LinkedHashNode(TElement value) + { + Value = value; + } + + public override string ToString() + { + return Value.ToString(); + } + } + + public struct Enumerator : IEnumerator + { + private LinkedHashNode _node; + private T _current; + + internal Enumerator(LinkedHashSet set) + { + _current = default(T); + _node = set.first; + } + + /// + public bool MoveNext() + { + if (_node == null) + { + return false; + } + + _current = _node.Value; + _node = _node.Next; + return true; + } + + /// + public T Current => _current; + + /// + object IEnumerator.Current => Current; + + /// + void IEnumerator.Reset() + { + throw new NotSupportedException(); + } + + /// + public void Dispose() + { + } + } + + public void AddMin(T item) + { + LinkedHashNode current = Last; + while (current != null && item.CompareTo(current.Value) < 0) + { + current = current.Previous; + } + + if (current == Last) + { + return; + } + + if (current == null) + { + AddBefore(item, First); + } + else + { + AddAfter(item, current); + } + } + + public void PushMin(T item) + { + LinkedHashNode current = Last; + while (current != null && item.CompareTo(current.Value) < 0) + { + current = current.Previous; + } + + if (current == Last) + { + return; + } + + if (current == null) + { + PushBefore(item, First); + } + else + { + PushAfter(item, current); + } + } + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Collections/MaxHeap.cs b/LightlessSync/ThirdParty/Nanomesh/Collections/MaxHeap.cs new file mode 100644 index 0000000..9164ffa --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Collections/MaxHeap.cs @@ -0,0 +1,86 @@ +using System; + +namespace Nanomesh +{ + public static class MaxHeap + { + public static T FindKthLargest(T[] nums, int k) where T : IComparable + { + Heap heap = new Heap(); + heap.Heapify(nums, nums.Length); + T data = default(T); + for (int i = 0; i < k; i++) + { + data = heap.RemoveMax(); + } + return data; + } + } + + public class Heap where T : IComparable + { + private T[] arr; + private int count; + private int size; + + public int GetLeftChild(int pos) + { + int l = 2 * pos + 1; + return l >= count ? -1 : l; + } + + public int GetRightChild(int pos) + { + int r = 2 * pos + 2; + return r >= count ? -1 : r; + } + + public void Heapify(T[] num, int n) + { + arr = new T[n]; + size = n; + for (int i = 0; i < n; i++) + { + arr[i] = num[i]; + } + + count = n; + + for (int i = (count - 1) / 2; i >= 0; i--) + { + PercolateDown(i); + } + } + public void PercolateDown(int pos) + { + int l = GetLeftChild(pos); + int r = GetRightChild(pos); + int max = pos; + if (l != -1 && arr[max].CompareTo(arr[l]) < 0) + { + max = l; + } + + if (r != -1 && arr[max].CompareTo(arr[r]) < 0) + { + max = r; + } + + if (max != pos) + { + T temp = arr[pos]; + arr[pos] = arr[max]; + arr[max] = temp; + PercolateDown(max); + } + } + public T RemoveMax() + { + T data = arr[0]; + arr[0] = arr[count - 1]; + count--; + PercolateDown(0); + return data; + } + } +} diff --git a/LightlessSync/ThirdParty/Nanomesh/Collections/MinHeap.cs b/LightlessSync/ThirdParty/Nanomesh/Collections/MinHeap.cs new file mode 100644 index 0000000..8b318c5 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Collections/MinHeap.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; + +namespace Nanomesh.Collections +{ + public class MinHeap : IEnumerable + { + private readonly List values; + private readonly IComparer comparer; + + public MinHeap(IEnumerable items, IComparer comparer) + { + values = new List(); + this.comparer = comparer; + values.Add(default(T)); + values.AddRange(items); + + for (int i = values.Count / 2; i >= 1; i--) + { + BubbleDown(i); + } + } + + public MinHeap(IEnumerable items) : this(items, Comparer.Default) { } + + public MinHeap(IComparer comparer) : this(new T[0], comparer) { } + + public MinHeap() : this(Comparer.Default) { } + + public int Count => values.Count - 1; + + public T Min => values[1]; + + /// + /// Extract the smallest element. + /// + /// + public T ExtractMin() + { + int count = Count; + + if (count == 0) + { + throw new InvalidOperationException("Heap is empty."); + } + + T min = Min; + values[1] = values[count]; + values.RemoveAt(count); + + if (values.Count > 1) + { + BubbleDown(1); + } + + return min; + } + + /// + /// Insert the value. + /// + /// + /// + public void Add(T item) + { + values.Add(item); + BubbleUp(Count); + } + + private void BubbleUp(int index) + { + int parent = index / 2; + + while (index > 1 && CompareResult(parent, index) > 0) + { + Exchange(index, parent); + index = parent; + parent /= 2; + } + } + + private void BubbleDown(int index) + { + int min; + + while (true) + { + int left = index * 2; + int right = index * 2 + 1; + + if (left < values.Count && + CompareResult(left, index) < 0) + { + min = left; + } + else + { + min = index; + } + + if (right < values.Count && + CompareResult(right, min) < 0) + { + min = right; + } + + if (min != index) + { + Exchange(index, min); + index = min; + } + else + { + return; + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private int CompareResult(int index1, int index2) + { + return comparer.Compare(values[index1], values[index2]); + } + + private void Exchange(int index, int max) + { + T tmp = values[index]; + values[index] = values[max]; + values[max] = tmp; + } + + public IEnumerator GetEnumerator() + { + return values.Skip(1).GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } +} diff --git a/LightlessSync/ThirdParty/Nanomesh/Collections/OrderStatistics.cs b/LightlessSync/ThirdParty/Nanomesh/Collections/OrderStatistics.cs new file mode 100644 index 0000000..dbc3726 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Collections/OrderStatistics.cs @@ -0,0 +1,118 @@ +using System; + +namespace Nanomesh +{ + public static class OrderStatistics + { + private static T FindMedian(T[] arr, int i, int n) + { + if (i <= n) + { + Array.Sort(arr, i, n); // Sort the array + } + else + { + Array.Sort(arr, n, i); + } + + return arr[n / 2]; // Return middle element + } + + // Returns k'th smallest element + // in arr[l..r] in worst case + // linear time. ASSUMPTION: ALL + // ELEMENTS IN ARR[] ARE DISTINCT + public static T FindKthSmallest(T[] arr, int l, int r, int k) where T : IComparable + { + // If k is smaller than + // number of elements in array + if (k > 0 && k <= r - l + 1) + { + int n = r - l + 1; // Number of elements in arr[l..r] + + // Divide arr[] in groups of size 5, + // calculate median of every group + // and store it in median[] array. + int i; + + // There will be floor((n+4)/5) groups; + T[] median = new T[(n + 4) / 5]; + for (i = 0; i < n / 5; i++) + { + median[i] = FindMedian(arr, l + i * 5, 5); + } + + // For last group with less than 5 elements + if (i * 5 < n) + { + median[i] = FindMedian(arr, l + i * 5, n % 5); + i++; + } + + // Find median of all medians using recursive call. + // If median[] has only one element, then no need + // of recursive call + T medOfMed = (i == 1) ? median[i - 1] : FindKthSmallest(median, 0, i - 1, i / 2); + + // Partition the array around a random element and + // get position of pivot element in sorted array + int pos = Partition(arr, l, r, medOfMed); + + // If position is same as k + if (pos - l == k - 1) + { + return arr[pos]; + } + + if (pos - l > k - 1) // If position is more, recur for left + { + return FindKthSmallest(arr, l, pos - 1, k); + } + + // Else recur for right subarray + return FindKthSmallest(arr, pos + 1, r, k - pos + l - 1); + } + + // If k is more than number of elements in array + return default(T); + } + + private static void Swap(ref T[] arr, int i, int j) + { + T temp = arr[i]; + arr[i] = arr[j]; + arr[j] = temp; + } + + // It searches for x in arr[l..r], and + // partitions the array around x. + private static int Partition(T[] arr, int l, int r, T x) where T : IComparable + { + // Search for x in arr[l..r] and move it to end + int i; + for (i = l; i < r; i++) + { + if (arr[i].CompareTo(x) == 0) + { + break; + } + } + + Swap(ref arr, i, r); + + // Standard partition algorithm + i = l; + for (int j = l; j <= r - 1; j++) + { + if (arr[j].CompareTo(x) <= 0) + { + Swap(ref arr, i, j); + i++; + } + } + Swap(ref arr, i, r); + return i; + } + + } +} diff --git a/LightlessSync/ThirdParty/Nanomesh/Mesh/Attributes/AttributeDefinition.cs b/LightlessSync/ThirdParty/Nanomesh/Mesh/Attributes/AttributeDefinition.cs new file mode 100644 index 0000000..3a60ca8 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Mesh/Attributes/AttributeDefinition.cs @@ -0,0 +1,30 @@ +namespace Nanomesh +{ + public struct AttributeDefinition + { + public double weight; + public AttributeType type; + public int id; + + public AttributeDefinition(AttributeType type) + { + this.weight = 1; + this.type = type; + this.id = 0; + } + + public AttributeDefinition(AttributeType type, double weight) + { + this.weight = weight; + this.type = type; + this.id = 0; + } + + public AttributeDefinition(AttributeType type, double weight, int id) + { + this.weight = weight; + this.type = type; + this.id = id; + } + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Mesh/Attributes/AttributeType.cs b/LightlessSync/ThirdParty/Nanomesh/Mesh/Attributes/AttributeType.cs new file mode 100644 index 0000000..24d596a --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Mesh/Attributes/AttributeType.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; + +namespace Nanomesh +{ + public enum AttributeType + { + Normals, + UVs, + BoneWeights, + Colors, + } + + public static class AttributeUtils + { + public static MetaAttributeList CreateAttributesFromDefinitions(IList attributeDefinitions) + { + MetaAttributeList attributeList = new EmptyMetaAttributeList(0); + for (int i = 0; i < attributeDefinitions.Count; i++) + { + switch (attributeDefinitions[i].type) + { + case AttributeType.Normals: + attributeList = attributeList.AddAttributeType(); + break; + case AttributeType.UVs: + attributeList = attributeList.AddAttributeType(); + break; + case AttributeType.BoneWeights: + attributeList = attributeList.AddAttributeType(); + break; + default: + throw new NotImplementedException(); + } + } + return attributeList; + } + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Mesh/Attributes/MetaAttribute.cs b/LightlessSync/ThirdParty/Nanomesh/Mesh/Attributes/MetaAttribute.cs new file mode 100644 index 0000000..a08c16a --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Mesh/Attributes/MetaAttribute.cs @@ -0,0 +1,406 @@ +using System; + +namespace Nanomesh +{ + public unsafe interface IMetaAttribute + { + IMetaAttribute Set(int index, K value) where K : unmanaged; + K Get(int index) where K : unmanaged; + } + + public unsafe struct MetaAttribute : IMetaAttribute + where T0 : unmanaged + { + public T0 attr0; + + public MetaAttribute(T0 attr0) + { + this.attr0 = attr0; + } + + public unsafe K Get(int index) where K : unmanaged + { + switch (index) + { + case 0: + fixed (T0* k = &attr0) + { + K* kk = (K*)k; + return kk[0]; + } + default: + throw new ArgumentOutOfRangeException(); + } + } + + public IMetaAttribute Set(int index, K value) where K : unmanaged + { + switch (index) + { + case 0: + fixed (T0* k = &attr0) + { + K* kk = (K*)k; + kk[0] = value; + return this; + } + default: + throw new ArgumentOutOfRangeException(); + } + } + + public override int GetHashCode() + { + return attr0.GetHashCode(); + } + + public override bool Equals(object obj) + { + return ((MetaAttribute)obj).attr0.Equals(attr0); + } + } + + public unsafe struct MetaAttribute : IMetaAttribute + where T0 : unmanaged + where T1 : unmanaged + { + public T0 attr0; + public T1 attr1; + + public MetaAttribute(T0 attr0, T1 attr1) + { + this.attr0 = attr0; + this.attr1 = attr1; + } + + public unsafe K Get(int index) where K : unmanaged + { + switch (index) + { + case 0: + fixed (T0* k = &attr0) + { + K* kk = (K*)k; + return kk[0]; + } + case 1: + fixed (T1* k = &attr1) + { + K* kk = (K*)k; + return kk[0]; + } + default: + throw new ArgumentOutOfRangeException(); + } + } + + public IMetaAttribute Set(int index, K value) where K : unmanaged + { + switch (index) + { + case 0: + fixed (T0* k = &attr0) + { + K* kk = (K*)k; + kk[0] = value; + return this; + } + case 1: + fixed (T1* k = &attr1) + { + K* kk = (K*)k; + kk[0] = value; + return this; + } + default: + throw new ArgumentOutOfRangeException(); + } + } + } + + public unsafe struct MetaAttribute : IMetaAttribute + where T0 : unmanaged + where T1 : unmanaged + where T2 : unmanaged + { + public T0 attr0; + public T1 attr1; + public T2 attr2; + + public MetaAttribute(T0 attr0, T1 attr1, T2 attr2) + { + this.attr0 = attr0; + this.attr1 = attr1; + this.attr2 = attr2; + } + + public unsafe K Get(int index) where K : unmanaged + { + switch (index) + { + case 0: + fixed (T0* k = &attr0) + { + K* kk = (K*)k; + return kk[0]; + } + case 1: + fixed (T1* k = &attr1) + { + K* kk = (K*)k; + return kk[0]; + } + case 2: + fixed (T2* k = &attr2) + { + K* kk = (K*)k; + return kk[0]; + } + default: + throw new ArgumentOutOfRangeException(); + } + } + + public IMetaAttribute Set(int index, K value) where K : unmanaged + { + switch (index) + { + case 0: + fixed (T0* k = &attr0) + { + K* kk = (K*)k; + kk[0] = value; + return this; + } + case 1: + fixed (T1* k = &attr1) + { + K* kk = (K*)k; + kk[0] = value; + return this; + } + case 2: + fixed (T2* k = &attr2) + { + K* kk = (K*)k; + kk[0] = value; + return this; + } + default: + throw new ArgumentOutOfRangeException(); + } + } + } + + public unsafe struct MetaAttribute : IMetaAttribute + where T0 : unmanaged + where T1 : unmanaged + where T2 : unmanaged + where T3 : unmanaged + { + public T0 attr0; + public T1 attr1; + public T2 attr2; + public T3 attr3; + + public MetaAttribute(T0 attr0, T1 attr1, T2 attr2, T3 attr3) + { + this.attr0 = attr0; + this.attr1 = attr1; + this.attr2 = attr2; + this.attr3 = attr3; + } + + public unsafe K Get(int index) where K : unmanaged + { + switch (index) + { + case 0: + fixed (T0* k = &attr0) + { + K* kk = (K*)k; + return kk[0]; + } + case 1: + fixed (T1* k = &attr1) + { + K* kk = (K*)k; + return kk[0]; + } + case 2: + fixed (T2* k = &attr2) + { + K* kk = (K*)k; + return kk[0]; + } + case 3: + fixed (T3* k = &attr3) + { + K* kk = (K*)k; + return kk[0]; + } + default: + throw new ArgumentOutOfRangeException(); + } + + // Shorter idea but only C# 8.0: + //fixed (void* v = &this) + //{ + // byte* b = (byte*)v; + // b += Positions[index]; + // return ((K*)b)[0]; + //}; + } + + public IMetaAttribute Set(int index, K value) where K : unmanaged + { + switch (index) + { + case 0: + fixed (T0* k = &attr0) + { + K* kk = (K*)k; + kk[0] = value; + return this; + } + case 1: + fixed (T1* k = &attr1) + { + K* kk = (K*)k; + kk[0] = value; + return this; + } + case 2: + fixed (T2* k = &attr2) + { + K* kk = (K*)k; + kk[0] = value; + return this; + } + case 3: + fixed (T3* k = &attr3) + { + K* kk = (K*)k; + kk[0] = value; + return this; + } + default: + throw new ArgumentOutOfRangeException(); + } + } + } + + public unsafe struct MetaAttribute : IMetaAttribute + where T0 : unmanaged + where T1 : unmanaged + where T2 : unmanaged + where T3 : unmanaged + where T4 : unmanaged + { + public T0 attr0; + public T1 attr1; + public T2 attr2; + public T3 attr3; + public T4 attr4; + + public MetaAttribute(T0 attr0, T1 attr1, T2 attr2, T3 attr3, T4 attr4) + { + this.attr0 = attr0; + this.attr1 = attr1; + this.attr2 = attr2; + this.attr3 = attr3; + this.attr4 = attr4; + } + + public unsafe K Get(int index) where K : unmanaged + { + switch (index) + { + case 0: + fixed (T0* k = &attr0) + { + K* kk = (K*)k; + return kk[0]; + } + case 1: + fixed (T1* k = &attr1) + { + K* kk = (K*)k; + return kk[0]; + } + case 2: + fixed (T2* k = &attr2) + { + K* kk = (K*)k; + return kk[0]; + } + case 3: + fixed (T3* k = &attr3) + { + K* kk = (K*)k; + return kk[0]; + } + case 4: + fixed (T4* k = &attr4) + { + K* kk = (K*)k; + return kk[0]; + } + default: + throw new ArgumentOutOfRangeException(); + } + + // Shorter idea but only C# 8.0: + //fixed (void* v = &this) + //{ + // byte* b = (byte*)v; + // b += Positions[index]; + // return ((K*)b)[0]; + //}; + } + + public IMetaAttribute Set(int index, K value) where K : unmanaged + { + switch (index) + { + case 0: + fixed (T0* k = &attr0) + { + K* kk = (K*)k; + kk[0] = value; + return this; + } + case 1: + fixed (T1* k = &attr1) + { + K* kk = (K*)k; + kk[0] = value; + return this; + } + case 2: + fixed (T2* k = &attr2) + { + K* kk = (K*)k; + kk[0] = value; + return this; + } + case 3: + fixed (T3* k = &attr3) + { + K* kk = (K*)k; + kk[0] = value; + return this; + } + case 4: + fixed (T4* k = &attr4) + { + K* kk = (K*)k; + kk[0] = value; + return this; + } + default: + throw new ArgumentOutOfRangeException(); + } + } + } +} diff --git a/LightlessSync/ThirdParty/Nanomesh/Mesh/Attributes/MetaAttributeList.cs b/LightlessSync/ThirdParty/Nanomesh/Mesh/Attributes/MetaAttributeList.cs new file mode 100644 index 0000000..048969c --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Mesh/Attributes/MetaAttributeList.cs @@ -0,0 +1,448 @@ +using System; + +namespace Nanomesh +{ + public abstract class MetaAttributeList + { + public abstract IMetaAttribute this[int index] + { + get; + set; + } + + public abstract int Count { get; } + + public abstract int CountPerAttribute { get; } + + public abstract MetaAttributeList CreateNew(int length); + + public abstract MetaAttributeList AddAttributeType() + where T : unmanaged, IInterpolable; + + public abstract bool Equals(int indexA, int indexB, int attribute); + + public abstract void Interpolate(int attribute, int indexA, int indexB, double ratio); + } + + public class EmptyMetaAttributeList : MetaAttributeList + { + private readonly int _length; + + public EmptyMetaAttributeList(int length) + { + _length = length; + } + + public override IMetaAttribute this[int index] + { + get => throw new System.Exception(); + set => throw new System.Exception(); + } + + public override MetaAttributeList CreateNew(int length) => new EmptyMetaAttributeList(length); + + public override unsafe bool Equals(int indexA, int indexB, int attribute) + { + return false; + } + + public override void Interpolate(int attribute, int indexA, int indexB, double ratio) + { + throw new System.Exception(); + } + + public override MetaAttributeList AddAttributeType() + { + return new MetaAttributeList(_length); + } + + public override int Count => 0; + + public override int CountPerAttribute => 0; + } + + public class MetaAttributeList : MetaAttributeList + where T0 : unmanaged, IInterpolable + { + private readonly MetaAttribute[] _attributes; + + public MetaAttributeList(int length) + { + _attributes = new MetaAttribute[length]; + } + + public override IMetaAttribute this[int index] + { + get => _attributes[index]; + set => _attributes[index] = (MetaAttribute)value; + } + + public void Set(MetaAttribute value, int index) + { + _attributes[index] = value; + } + + private void Get(MetaAttribute value, int index) + { + _attributes[index] = value; + } + + public override MetaAttributeList CreateNew(int length) => new MetaAttributeList(length); + + public override unsafe bool Equals(int indexA, int indexB, int attribute) + { + switch (attribute) + { + case 0: + return _attributes[indexA].Get(0).Equals(_attributes[indexB].Get(0)); + default: + throw new ArgumentOutOfRangeException(); + } + } + + public override void Interpolate(int attribute, int indexA, int indexB, double ratio) + { + _attributes[indexA].attr0 = _attributes[indexA].Get(0).Interpolate(_attributes[indexB].Get(0), ratio); + _attributes[indexB].attr0 = _attributes[indexA].attr0; + } + + public override MetaAttributeList AddAttributeType() + { + MetaAttributeList newAttributes = new MetaAttributeList(_attributes.Length); + for (int i = 0; i < Count; i++) + newAttributes.Set(new MetaAttribute(_attributes[i].attr0, default(T)), i); + return newAttributes; + } + + public override int Count => _attributes.Length; + + public override int CountPerAttribute => 1; + } + + public class MetaAttributeList : MetaAttributeList + where T0 : unmanaged, IInterpolable + where T1 : unmanaged, IInterpolable + { + private readonly MetaAttribute[] _attributes; + + public MetaAttributeList(int length) + { + _attributes = new MetaAttribute[length]; + } + + public override IMetaAttribute this[int index] + { + get => _attributes[index]; + set => _attributes[index] = (MetaAttribute)value; + } + + public void Set(MetaAttribute value, int index) + { + _attributes[index] = value; + } + + private void Get(MetaAttribute value, int index) + { + _attributes[index] = value; + } + + public override MetaAttributeList CreateNew(int length) => new MetaAttributeList(length); + + public override unsafe bool Equals(int indexA, int indexB, int attribute) + { + switch (attribute) + { + case 0: + return _attributes[indexA].Get(0).Equals(_attributes[indexB].Get(0)); + case 1: + return _attributes[indexA].Get(1).Equals(_attributes[indexB].Get(1)); + default: + throw new ArgumentOutOfRangeException(); + } + } + + public override void Interpolate(int attribute, int indexA, int indexB, double ratio) + { + switch (attribute) + { + case 0: + _attributes[indexA].attr0 = _attributes[indexA].Get(0).Interpolate(_attributes[indexB].Get(0), ratio); + _attributes[indexB].attr0 = _attributes[indexA].attr0; + break; + case 1: + _attributes[indexA].attr1 = _attributes[indexA].Get(1).Interpolate(_attributes[indexB].Get(1), ratio); + _attributes[indexB].attr1 = _attributes[indexA].attr1; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + public override MetaAttributeList AddAttributeType() + { + MetaAttributeList newAttributes = new MetaAttributeList(_attributes.Length); + for (int i = 0; i < Count; i++) + newAttributes.Set(new MetaAttribute(_attributes[i].attr0, _attributes[i].attr1, default(T)), i); + return newAttributes; + } + + public override int Count => _attributes.Length; + + public override int CountPerAttribute => 2; + } + + public class MetaAttributeList : MetaAttributeList + where T0 : unmanaged, IInterpolable + where T1 : unmanaged, IInterpolable + where T2 : unmanaged, IInterpolable + { + private readonly MetaAttribute[] _attributes; + + public MetaAttributeList(int length) + { + _attributes = new MetaAttribute[length]; + } + + public override IMetaAttribute this[int index] + { + get => _attributes[index]; + set => _attributes[index] = (MetaAttribute)value; + } + + public void Set(MetaAttribute value, int index) + { + _attributes[index] = value; + } + + private void Get(MetaAttribute value, int index) + { + _attributes[index] = value; + } + + public override MetaAttributeList CreateNew(int length) => new MetaAttributeList(length); + + public override unsafe bool Equals(int indexA, int indexB, int attribute) + { + switch (attribute) + { + case 0: + return _attributes[indexA].Get(0).Equals(_attributes[indexB].Get(0)); + case 1: + return _attributes[indexA].Get(1).Equals(_attributes[indexB].Get(1)); + case 2: + return _attributes[indexA].Get(2).Equals(_attributes[indexB].Get(2)); + default: + throw new ArgumentOutOfRangeException(); + } + } + + public override void Interpolate(int attribute, int indexA, int indexB, double ratio) + { + switch (attribute) + { + case 0: + _attributes[indexA].attr0 = _attributes[indexA].Get(0).Interpolate(_attributes[indexB].Get(0), ratio); + _attributes[indexB].attr0 = _attributes[indexA].attr0; + break; + case 1: + _attributes[indexA].attr1 = _attributes[indexA].Get(1).Interpolate(_attributes[indexB].Get(1), ratio); + _attributes[indexB].attr1 = _attributes[indexA].attr1; + break; + case 2: + _attributes[indexA].attr2 = _attributes[indexA].Get(2).Interpolate(_attributes[indexB].Get(2), ratio); + _attributes[indexB].attr2 = _attributes[indexA].attr2; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + public override MetaAttributeList AddAttributeType() + { + MetaAttributeList newAttributes = new MetaAttributeList(_attributes.Length); + for (int i = 0; i < Count; i++) + newAttributes.Set(new MetaAttribute(_attributes[i].attr0, _attributes[i].attr1, _attributes[i].attr2, default(T)), i); + return newAttributes; + } + + public override int Count => _attributes.Length; + + public override int CountPerAttribute => 3; + } + + public class MetaAttributeList : MetaAttributeList + where T0 : unmanaged, IInterpolable + where T1 : unmanaged, IInterpolable + where T2 : unmanaged, IInterpolable + where T3 : unmanaged, IInterpolable + { + private readonly MetaAttribute[] _attributes; + + public MetaAttributeList(int length) + { + _attributes = new MetaAttribute[length]; + } + + public override IMetaAttribute this[int index] + { + get => _attributes[index]; + set => _attributes[index] = (MetaAttribute)value; + } + + public void Set(MetaAttribute value, int index) + { + _attributes[index] = value; + } + + private void Get(MetaAttribute value, int index) + { + _attributes[index] = value; + } + + public override MetaAttributeList CreateNew(int length) => new MetaAttributeList(length); + + public override unsafe bool Equals(int indexA, int indexB, int attribute) + { + switch (attribute) + { + case 0: + return _attributes[indexA].Get(0).Equals(_attributes[indexB].Get(0)); + case 1: + return _attributes[indexA].Get(1).Equals(_attributes[indexB].Get(1)); + case 2: + return _attributes[indexA].Get(2).Equals(_attributes[indexB].Get(2)); + case 3: + return _attributes[indexA].Get(3).Equals(_attributes[indexB].Get(3)); + default: + throw new ArgumentOutOfRangeException(); + } + } + + public override void Interpolate(int attribute, int indexA, int indexB, double ratio) + { + switch (attribute) + { + case 0: + _attributes[indexA].attr0 = _attributes[indexA].Get(0).Interpolate(_attributes[indexB].Get(0), ratio); + _attributes[indexB].attr0 = _attributes[indexA].attr0; + break; + case 1: + _attributes[indexA].attr1 = _attributes[indexA].Get(1).Interpolate(_attributes[indexB].Get(1), ratio); + _attributes[indexB].attr1 = _attributes[indexA].attr1; + break; + case 2: + _attributes[indexA].attr2 = _attributes[indexA].Get(2).Interpolate(_attributes[indexB].Get(2), ratio); + _attributes[indexB].attr2 = _attributes[indexA].attr2; + break; + case 3: + _attributes[indexA].attr3 = _attributes[indexA].Get(3).Interpolate(_attributes[indexB].Get(3), ratio); + _attributes[indexB].attr3 = _attributes[indexA].attr3; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + public override MetaAttributeList AddAttributeType() + { + MetaAttributeList newAttributes = new MetaAttributeList(_attributes.Length); + for (int i = 0; i < Count; i++) + newAttributes.Set(new MetaAttribute(_attributes[i].attr0, _attributes[i].attr1, _attributes[i].attr2, _attributes[i].attr3, default(T)), i); + return newAttributes; + } + + public override int Count => _attributes.Length; + + public override int CountPerAttribute => 4; + } + + public class MetaAttributeList : MetaAttributeList + where T0 : unmanaged, IInterpolable + where T1 : unmanaged, IInterpolable + where T2 : unmanaged, IInterpolable + where T3 : unmanaged, IInterpolable + where T4 : unmanaged, IInterpolable + { + private readonly MetaAttribute[] _attributes; + + public MetaAttributeList(int length) + { + _attributes = new MetaAttribute[length]; + } + + public override IMetaAttribute this[int index] + { + get => _attributes[index]; + set => _attributes[index] = (MetaAttribute)value; + } + + public void Set(MetaAttribute value, int index) + { + _attributes[index] = value; + } + + private void Get(MetaAttribute value, int index) + { + _attributes[index] = value; + } + + public override MetaAttributeList CreateNew(int length) => new MetaAttributeList(length); + + public override unsafe bool Equals(int indexA, int indexB, int attribute) + { + switch (attribute) + { + case 0: + return _attributes[indexA].Get(0).Equals(_attributes[indexB].Get(0)); + case 1: + return _attributes[indexA].Get(1).Equals(_attributes[indexB].Get(1)); + case 2: + return _attributes[indexA].Get(2).Equals(_attributes[indexB].Get(2)); + case 3: + return _attributes[indexA].Get(3).Equals(_attributes[indexB].Get(3)); + case 4: + return _attributes[indexA].Get(3).Equals(_attributes[indexB].Get(4)); + default: + throw new ArgumentOutOfRangeException(); + } + } + + public override void Interpolate(int attribute, int indexA, int indexB, double ratio) + { + switch (attribute) + { + case 0: + _attributes[indexA].attr0 = _attributes[indexA].Get(0).Interpolate(_attributes[indexB].Get(0), ratio); + _attributes[indexB].attr0 = _attributes[indexA].attr0; + break; + case 1: + _attributes[indexA].attr1 = _attributes[indexA].Get(1).Interpolate(_attributes[indexB].Get(1), ratio); + _attributes[indexB].attr1 = _attributes[indexA].attr1; + break; + case 2: + _attributes[indexA].attr2 = _attributes[indexA].Get(2).Interpolate(_attributes[indexB].Get(2), ratio); + _attributes[indexB].attr2 = _attributes[indexA].attr2; + break; + case 3: + _attributes[indexA].attr3 = _attributes[indexA].Get(3).Interpolate(_attributes[indexB].Get(3), ratio); + _attributes[indexB].attr3 = _attributes[indexA].attr3; + break; + case 4: + _attributes[indexA].attr4 = _attributes[indexA].Get(4).Interpolate(_attributes[indexB].Get(4), ratio); + _attributes[indexB].attr4 = _attributes[indexA].attr4; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + public override MetaAttributeList AddAttributeType() + { + throw new NotImplementedException(); + } + + public override int Count => _attributes.Length; + + public override int CountPerAttribute => 5; + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Mesh/ConnectedMesh/ConnectedMesh.cs b/LightlessSync/ThirdParty/Nanomesh/Mesh/ConnectedMesh/ConnectedMesh.cs new file mode 100644 index 0000000..2cb81c8 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Mesh/ConnectedMesh/ConnectedMesh.cs @@ -0,0 +1,706 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Nanomesh +{ + // Let's say F = 2V + // Halfedge mesh is V * sizeof(vertex) + 3F * sizeof(Halfedge) + F * sizeof(Face) = 16 * 0.5F + 3F * 20 + 4F = 72F + // Connected mesh is V * sizeof(Vector3) + 3F * sizeof(Node) + F * sizeof(Face) = 12 * 0.5F + 3F * 12 + 12F = 54F (without attributes) + // Connected mesh no face is V * sizeof(Vector3) + 3F * sizeof(Node) = 12 * 0.5F + 3F * 12 = 42F (without attributes) + public partial class ConnectedMesh + { + // Todo : make this private (can only be modified from the inside) + public Vector3[] positions; + public MetaAttributeList attributes; + public Node[] nodes; + public Group[] groups; + public AttributeDefinition[] attributeDefinitions; + + public int[] PositionToNode => _positionToNode ?? (_positionToNode = GetPositionToNode()); + private int[] _positionToNode; + + internal int _faceCount; + public int FaceCount => _faceCount; + + public bool AreNodesSiblings(int nodeIndexA, int nodeIndexB) + { + return nodes[nodeIndexA].position == nodes[nodeIndexB].position; + } + + public int[] GetPositionToNode() + { + int[] positionToNode = new int[positions.Length]; + + for (int i = 0; i < positions.Length; i++) + { + positionToNode[i] = -1; + } + + for (int i = 0; i < nodes.Length; i++) + { + if (!nodes[i].IsRemoved) + { + positionToNode[nodes[i].position] = i; + } + } + + return positionToNode; + } + + public int GetEdgeCount(int nodeIndex) + { + return GetRelativesCount(nodeIndex) + 1; + } + + public int GetRelativesCount(int nodeIndex) + { + int k = 0; + int relative = nodeIndex; + while ((relative = nodes[relative].relative) != nodeIndex) + { + k++; + } + return k; + } + + public int GetSiblingsCount(int nodeIndex) + { + int k = 0; + int sibling = nodeIndex; + while ((sibling = nodes[sibling].sibling) != nodeIndex) + { + k++; + } + return k; + } + + public int ReconnectSiblings(int nodeIndex) + { + int sibling = nodeIndex; + int lastValid = -1; + int firstValid = -1; + int position = -1; + + do + { + if (nodes[sibling].IsRemoved) + { + continue; + } + + if (firstValid == -1) + { + firstValid = sibling; + position = nodes[sibling].position; + } + + if (lastValid != -1) + { + nodes[lastValid].sibling = sibling; + nodes[lastValid].position = position; + } + + lastValid = sibling; + } + while ((sibling = nodes[sibling].sibling) != nodeIndex); + + if (lastValid == -1) + { + return -1; // All siblings were removed + } + + // Close the loop + nodes[lastValid].sibling = firstValid; + nodes[lastValid].position = position; + + return firstValid; + } + + public int ReconnectSiblings(int nodeIndexA, int nodeIndexB, int position) + { + int sibling = nodeIndexA; + int lastValid = -1; + int firstValid = -1; + + do + { + if (nodes[sibling].IsRemoved) + { + continue; + } + + if (firstValid == -1) + { + firstValid = sibling; + //position = nodes[sibling].position; + } + + if (lastValid != -1) + { + nodes[lastValid].sibling = sibling; + nodes[lastValid].position = position; + } + + lastValid = sibling; + } + while ((sibling = nodes[sibling].sibling) != nodeIndexA); + + sibling = nodeIndexB; + do + { + if (nodes[sibling].IsRemoved) + { + continue; + } + + if (firstValid == -1) + { + firstValid = sibling; + //position = nodes[sibling].position; + } + + if (lastValid != -1) + { + nodes[lastValid].sibling = sibling; + nodes[lastValid].position = position; + } + + lastValid = sibling; + } + while ((sibling = nodes[sibling].sibling) != nodeIndexB); + + if (lastValid == -1) + { + return -1; // All siblings were removed + } + + // Close the loop + nodes[lastValid].sibling = firstValid; + nodes[lastValid].position = position; + + return firstValid; + } + + public int CollapseEdge(int nodeIndexA, int nodeIndexB) + { + int posA = nodes[nodeIndexA].position; + int posB = nodes[nodeIndexB].position; + + Debug.Assert(posA != posB, "A and B must have different positions"); + Debug.Assert(!nodes[nodeIndexA].IsRemoved); + Debug.Assert(!nodes[nodeIndexB].IsRemoved); + + Debug.Assert(CheckRelatives(nodeIndexA), "A's relatives must be valid"); + Debug.Assert(CheckRelatives(nodeIndexB), "B's relatives must be valid"); + Debug.Assert(CheckSiblings(nodeIndexA), "A's siblings must be valid"); + Debug.Assert(CheckSiblings(nodeIndexB), "B's siblings must be valid"); + + int siblingOfA = nodeIndexA; + do // Iterates over faces around A + { + bool isFaceTouched = false; + int faceEdgeCount = 0; + int nodeIndexC = -1; + + int relativeOfA = siblingOfA; + do // Circulate in face + { + int posC = nodes[relativeOfA].position; + if (posC == posB) + { + isFaceTouched = true; + } + else if (posC != posA) + { + nodeIndexC = relativeOfA; + } + + faceEdgeCount++; + } while ((relativeOfA = nodes[relativeOfA].relative) != siblingOfA); + + if (faceEdgeCount != 3) + throw new NotImplementedException(); + + if (isFaceTouched && faceEdgeCount == 3) + { + // Remove face : Mark nodes as removed an reconnect siblings around C + + int posC = nodes[nodeIndexC].position; + + relativeOfA = siblingOfA; + do + { + nodes[relativeOfA].MarkRemoved(); + + } while ((relativeOfA = nodes[relativeOfA].relative) != siblingOfA); + + int validNodeAtC = ReconnectSiblings(nodeIndexC); + + if (_positionToNode != null) + { + _positionToNode[posC] = validNodeAtC; + } + + _faceCount--; + } + } while ((siblingOfA = nodes[siblingOfA].sibling) != nodeIndexA); + + int validNodeAtA = ReconnectSiblings(nodeIndexA, nodeIndexB, posA); + + if (_positionToNode != null) + { + _positionToNode[posA] = validNodeAtA; + _positionToNode[posB] = -1; + } + + return validNodeAtA; + } + + public double GetEdgeTopo(int nodeIndexA, int nodeIndexB) + { + if ((uint)nodeIndexA >= (uint)nodes.Length || (uint)nodeIndexB >= (uint)nodes.Length) + { + return EdgeBorderPenalty; + } + + if (nodes[nodeIndexA].IsRemoved || nodes[nodeIndexB].IsRemoved) + { + return EdgeBorderPenalty; + } + + int posB = nodes[nodeIndexB].position; + + int facesAttached = 0; + + int attrAtA = -1; + int attrAtB = -1; + + double edgeWeight = 0; + + int siblingOfA = nodeIndexA; + do + { + int relativeOfA = siblingOfA; + while ((relativeOfA = nodes[relativeOfA].relative) != siblingOfA) + { + int posC = nodes[relativeOfA].position; + if (posC == posB) + { + facesAttached++; + + if (attributes != null) + { + for (int i = 0; i < attributes.CountPerAttribute; i++) + { + if (attrAtB != -1 && !attributes.Equals(attrAtB, nodes[relativeOfA].attribute, i)) + { + edgeWeight += attributeDefinitions[i].weight; + } + + if (attrAtA != -1 && !attributes.Equals(attrAtA, nodes[siblingOfA].attribute, i)) + { + edgeWeight += attributeDefinitions[i].weight; + } + } + } + + attrAtB = nodes[relativeOfA].attribute; + attrAtA = nodes[siblingOfA].attribute; + } + } + } while ((siblingOfA = nodes[siblingOfA].sibling) != nodeIndexA); + + if (facesAttached != 2) // Border or non-manifold edge + { + edgeWeight += EdgeBorderPenalty; + } + + return edgeWeight; + } + + internal static double EdgeBorderPenalty = 1027.007; + + // TODO : Make it work with any polygon (other than triangle) + public Vector3 GetFaceNormal(int nodeIndex) + { + int posA = nodes[nodeIndex].position; + int posB = nodes[nodes[nodeIndex].relative].position; + int posC = nodes[nodes[nodes[nodeIndex].relative].relative].position; + + Vector3 normal = Vector3.Cross( + positions[posB] - positions[posA], + positions[posC] - positions[posA]); + + return normal.Normalized; + } + + // TODO : Make it work with any polygon (other than triangle) + public double GetFaceArea(int nodeIndex) + { + int posA = nodes[nodeIndex].position; + int posB = nodes[nodes[nodeIndex].relative].position; + int posC = nodes[nodes[nodes[nodeIndex].relative].relative].position; + + Vector3 normal = Vector3.Cross( + positions[posB] - positions[posA], + positions[posC] - positions[posA]); + + return 0.5 * normal.Length; + } + + // Only works with triangles ! + public double GetAngleRadians(int nodeIndex) + { + int posA = nodes[nodeIndex].position; + int posB = nodes[nodes[nodeIndex].relative].position; + int posC = nodes[nodes[nodes[nodeIndex].relative].relative].position; + + return Vector3.AngleRadians( + positions[posB] - positions[posA], + positions[posC] - positions[posA]); + } + + public void Compact() + { + // Rebuild nodes array with only valid nodes + { + int validNodesCount = 0; + for (int i = 0; i < nodes.Length; i++) + if (!nodes[i].IsRemoved) + validNodesCount++; + + Node[] newNodes = new Node[validNodesCount]; + int k = 0; + Dictionary oldToNewNodeIndex = new Dictionary(); + for (int i = 0; i < nodes.Length; i++) + { + if (!nodes[i].IsRemoved) + { + newNodes[k] = nodes[i]; + oldToNewNodeIndex.Add(i, k); + k++; + } + } + for (int i = 0; i < newNodes.Length; i++) + { + newNodes[i].relative = oldToNewNodeIndex[newNodes[i].relative]; + newNodes[i].sibling = oldToNewNodeIndex[newNodes[i].sibling]; + } + nodes = newNodes; + } + + // Remap positions + { + Dictionary oldToNewPosIndex = new Dictionary(); + for (int i = 0; i < nodes.Length; i++) + { + if (!oldToNewPosIndex.ContainsKey(nodes[i].position)) + oldToNewPosIndex.Add(nodes[i].position, oldToNewPosIndex.Count); + + nodes[i].position = oldToNewPosIndex[nodes[i].position]; + } + Vector3[] newPositions = new Vector3[oldToNewPosIndex.Count]; + foreach (KeyValuePair oldToNewPos in oldToNewPosIndex) + { + newPositions[oldToNewPos.Value] = positions[oldToNewPos.Key]; + } + positions = newPositions; + } + + // Remap attributes + if (attributes != null) + { + Dictionary oldToNewAttrIndex = new Dictionary(); + for (int i = 0; i < nodes.Length; i++) + { + if (!oldToNewAttrIndex.ContainsKey(nodes[i].attribute)) + oldToNewAttrIndex.Add(nodes[i].attribute, oldToNewAttrIndex.Count); + + nodes[i].attribute = oldToNewAttrIndex[nodes[i].attribute]; + } + MetaAttributeList newAttributes = attributes.CreateNew(oldToNewAttrIndex.Count); + foreach (KeyValuePair oldToNewAttr in oldToNewAttrIndex) + { + newAttributes[oldToNewAttr.Value] = attributes[oldToNewAttr.Key]; + } + attributes = newAttributes; + } + + _positionToNode = null; // Invalid now + } + + public void MergePositions(double tolerance = 0.01) + { + Dictionary newPositions = new Dictionary(tolerance <= 0 ? null : new Vector3Comparer(tolerance)); + + for (int i = 0; i < positions.Length; i++) + { + newPositions.TryAdd(positions[i], newPositions.Count); + } + + for (int i = 0; i < nodes.Length; i++) + { + nodes[i].position = newPositions[positions[nodes[i].position]]; + } + + positions = new Vector3[newPositions.Count]; + foreach (KeyValuePair pair in newPositions) + { + positions[pair.Value] = pair.Key; + } + + newPositions = null; + + // Remapping siblings + Dictionary posToLastSibling = new Dictionary(); + + for (int i = 0; i < nodes.Length; i++) + { + if (posToLastSibling.ContainsKey(nodes[i].position)) + { + nodes[i].sibling = posToLastSibling[nodes[i].position]; + posToLastSibling[nodes[i].position] = i; + } + else + { + nodes[i].sibling = -1; + posToLastSibling.Add(nodes[i].position, i); + } + } + + for (int i = 0; i < nodes.Length; i++) + { + if (nodes[i].sibling < 0) + { + // Assign last sibling to close sibling loop + nodes[i].sibling = posToLastSibling[nodes[i].position]; + } + } + + _positionToNode = null; + + // Dereference faces that no longer exist + for (int i = 0; i < nodes.Length; i++) + { + if (nodes[i].IsRemoved) + { + continue; + } + + int lastPos = nodes[i].position; + int relative = i; + while ((relative = nodes[relative].relative) != i) // Circulate around face + { + int currPos = nodes[relative].position; + if (lastPos == currPos) + { + RemoveFace(relative); + break; + } + lastPos = currPos; + } + } + } + + public void MergeAttributes() + { + Dictionary _uniqueAttributes = new Dictionary(); + + for (int i = 0; i < nodes.Length; i++) + { + _uniqueAttributes.TryAdd(attributes[nodes[i].attribute], nodes[i].attribute); + } + + for (int i = 0; i < nodes.Length; i++) + { + nodes[i].attribute = _uniqueAttributes[attributes[nodes[i].attribute]]; + } + } + + public void RemoveFace(int nodeIndex) + { + int relative = nodeIndex; + do + { + nodes[relative].MarkRemoved(); + ReconnectSiblings(relative); + } while ((relative = nodes[relative].relative) != nodeIndex); + _faceCount--; + } + + public void Scale(double factor) + { + for (int i = 0; i < positions.Length; i++) + { + positions[i] = positions[i] * factor; + } + } + + public HashSet GetAllEdges() + { + HashSet edges = new HashSet(); + for (int p = 0; p < PositionToNode.Length; p++) + { + int nodeIndex = PositionToNode[p]; + if (nodeIndex < 0) + { + continue; + } + + int sibling = nodeIndex; + do + { + int firstRelative = nodes[sibling].relative; + int secondRelative = nodes[firstRelative].relative; + + Edge pair = new Edge(nodes[firstRelative].position, nodes[secondRelative].position); + + edges.Add(pair); + + } while ((sibling = nodes[sibling].sibling) != nodeIndex); + } + + return edges; + } + + public SharedMesh ToSharedMesh() + { + // Compating here is an issue if mesh is being decimated :/ + //Compact(); + + SharedMesh mesh = new SharedMesh(); + + List triangles = new List(); + HashSet browsedNodes = new HashSet(); + + Group[] newGroups = new Group[groups?.Length ?? 0]; + mesh.groups = newGroups; + mesh.attributeDefinitions = attributeDefinitions; + + int currentGroup = 0; + int indicesInGroup = 0; + + Dictionary<(int, int), int> perVertexMap = new Dictionary<(int, int), int>(); + + for (int i = 0; i < nodes.Length; i++) + { + if (newGroups.Length > 0 && groups[currentGroup].firstIndex == i) + { + if (currentGroup > 0) + { + newGroups[currentGroup - 1].indexCount = indicesInGroup; + newGroups[currentGroup].firstIndex = indicesInGroup + newGroups[currentGroup - 1].firstIndex; + } + indicesInGroup = 0; + if (currentGroup < groups.Length - 1) + { + currentGroup++; + } + } + + if (nodes[i].IsRemoved) + { + continue; + } + + indicesInGroup++; + + if (browsedNodes.Contains(i)) + { + continue; + } + + // Only works if all elements are triangles + int relative = i; + do + { + if (browsedNodes.Add(relative) && !nodes[relative].IsRemoved) + { + (int position, int attribute) key = (nodes[relative].position, nodes[relative].attribute); + perVertexMap.TryAdd(key, perVertexMap.Count); + triangles.Add(perVertexMap[key]); + } + } while ((relative = nodes[relative].relative) != i); + } + + if (newGroups.Length > 0) + { + newGroups[currentGroup].indexCount = indicesInGroup; + } + + // Positions + mesh.positions = new Vector3[perVertexMap.Count]; + foreach (KeyValuePair<(int, int), int> mapping in perVertexMap) + { + mesh.positions[mapping.Value] = positions[mapping.Key.Item1]; + } + + // Attributes + if (attributes != null && attributeDefinitions.Length > 0) + { + mesh.attributes = attributes.CreateNew(perVertexMap.Count); + foreach (KeyValuePair<(int, int), int> mapping in perVertexMap) + { + mesh.attributes[mapping.Value] = attributes[mapping.Key.Item2]; + } + } + + mesh.triangles = triangles.ToArray(); + + return mesh; + } + } + + public struct Edge : IEquatable + { + public int posA; + public int posB; + + public Edge(int posA, int posB) + { + this.posA = posA; + this.posB = posB; + } + + public override int GetHashCode() + { + unchecked + { + return posA + posB; + } + } + + public override bool Equals(object obj) + { + return Equals((Edge)obj); + } + + public bool Equals(Edge pc) + { + if (ReferenceEquals(this, pc)) + { + return true; + } + else + { + return (posA == pc.posA && posB == pc.posB) || (posA == pc.posB && posB == pc.posA); + } + } + + public static bool operator ==(Edge x, Edge y) + { + return x.Equals(y); + } + + public static bool operator !=(Edge x, Edge y) + { + return !x.Equals(y); + } + + public override string ToString() + { + return $""; + } + } +} diff --git a/LightlessSync/ThirdParty/Nanomesh/Mesh/ConnectedMesh/Debug.cs b/LightlessSync/ThirdParty/Nanomesh/Mesh/ConnectedMesh/Debug.cs new file mode 100644 index 0000000..818cb62 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Mesh/ConnectedMesh/Debug.cs @@ -0,0 +1,138 @@ +using System; +using System.Linq; + +namespace Nanomesh +{ + public partial class ConnectedMesh + { + internal string PrintSiblings(int nodeIndex) + { + int sibling = nodeIndex; + string text = string.Join(" > ", Enumerable.Range(0, 12).Select(x => + { + string res = sibling.ToString() + (nodes[sibling].IsRemoved ? "(x)" : $"({nodes[sibling].position})"); + sibling = nodes[sibling].sibling; + return res; + })); + return text + "..."; + } + + internal string PrintRelatives(int nodeIndex) + { + int relative = nodeIndex; + string text = string.Join(" > ", Enumerable.Range(0, 12).Select(x => + { + string res = relative.ToString() + (nodes[relative].IsRemoved ? "(x)" : $"({nodes[relative].position})"); + relative = nodes[relative].relative; + return res; + })); + return text + "..."; + } + + internal bool CheckEdge(int nodeIndexA, int nodeIndexB) + { + if (nodes[nodeIndexA].position == nodes[nodeIndexB].position) + { + throw new Exception("Positions must be different"); + } + + if (nodes[nodeIndexA].IsRemoved) + { + throw new Exception($"Node A is unreferenced {nodeIndexA}"); + } + + if (nodes[nodeIndexB].IsRemoved) + { + throw new Exception($"Node B is unreferenced {nodeIndexB}"); + } + + return true; + } + + internal bool CheckRelatives(int nodeIndex) + { + if (nodes[nodeIndex].IsRemoved) + { + throw new Exception($"Node {nodeIndex} is removed"); + } + + int relative = nodeIndex; + int edgecount = 0; + int prevPos = -2; + do + { + if (nodes[relative].position == prevPos) + { + throw new Exception($"Two relatives or more share the same position : {PrintRelatives(nodeIndex)}"); + } + + if (edgecount > 50) + { + throw new Exception($"Circularity relative violation : {PrintRelatives(nodeIndex)}"); + } + + if (nodes[relative].IsRemoved) + { + throw new Exception($"Node {nodeIndex} is connected to the deleted relative {relative}"); + } + + prevPos = nodes[relative].position; + edgecount++; + + } while ((relative = nodes[relative].relative) != nodeIndex); + + return true; + } + + internal bool CheckSiblings(int nodeIndex) + { + if (nodes[nodeIndex].IsRemoved) + { + throw new Exception($"Node {nodeIndex} is removed"); + } + + int sibling = nodeIndex; + int cardinality = 0; + do + { + if (cardinality > 1000) + { + //throw new Exception($"Node {i}'s cardinality is superior to 50. It is likely to be that face siblings are not circularily linked"); + throw new Exception($"Circularity sibling violation : {PrintSiblings(nodeIndex)}"); + } + + if (nodes[sibling].IsRemoved) + { + throw new Exception($"Node {nodeIndex} has a deleted sibling {sibling}"); + } + + cardinality++; + + } while ((sibling = nodes[sibling].sibling) != nodeIndex); + + return true; + } + + internal bool Check() + { + for (int nodeIndex = 0; nodeIndex < nodes.Length; nodeIndex++) + { + if (nodes[nodeIndex].IsRemoved) + { + continue; + } + + CheckRelatives(nodeIndex); + + CheckSiblings(nodeIndex); + + if (GetEdgeCount(nodeIndex) == 2) + { + throw new Exception($"Node {nodeIndex} is part of a polygon of degree 2"); + } + } + + return true; + } + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Mesh/ConnectedMesh/Node.cs b/LightlessSync/ThirdParty/Nanomesh/Mesh/ConnectedMesh/Node.cs new file mode 100644 index 0000000..1e92240 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Mesh/ConnectedMesh/Node.cs @@ -0,0 +1,25 @@ +namespace Nanomesh +{ + public partial class ConnectedMesh + { + public struct Node + { + public int position; + public int sibling; + public int relative; + public int attribute; + + public void MarkRemoved() + { + position = -10; + } + + public bool IsRemoved => position == -10; + + public override string ToString() + { + return $"sibl:{sibling} rela:{relative} posi:{position}"; + } + } + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Mesh/Group.cs b/LightlessSync/ThirdParty/Nanomesh/Mesh/Group.cs new file mode 100644 index 0000000..87dab9c --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Mesh/Group.cs @@ -0,0 +1,8 @@ +namespace Nanomesh +{ + public struct Group + { + public int firstIndex; + public int indexCount; + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Mesh/SharedMesh.cs b/LightlessSync/ThirdParty/Nanomesh/Mesh/SharedMesh.cs new file mode 100644 index 0000000..eef5c31 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Mesh/SharedMesh.cs @@ -0,0 +1,118 @@ +using System.Collections.Generic; +using System.Diagnostics; + +namespace Nanomesh +{ + /// + /// A shared mesh is a flattened approach of the triangle mesh. + /// Is does not has connectivity information, but it is simple to create + /// and is a rather lightweight mesh data structure. + /// + public class SharedMesh + { + public Vector3[] positions; + public int[] triangles; + public Group[] groups; + public MetaAttributeList attributes; + public AttributeDefinition[] attributeDefinitions; + + [Conditional("DEBUG")] + public void CheckLengths() + { + //if (attributes != null) + //{ + // foreach (var pair in attributes) + // { + // Debug.Assert(pair.Value.Length == vertices.Length, $"Attribute '{pair.Value}' must have as many elements as vertices"); + // } + //} + } + + public ConnectedMesh ToConnectedMesh() + { + CheckLengths(); + + ConnectedMesh connectedMesh = new ConnectedMesh + { + groups = groups + }; + + connectedMesh.positions = positions; + connectedMesh.attributes = attributes; + connectedMesh.attributeDefinitions = attributeDefinitions; + + // Building relatives + ConnectedMesh.Node[] nodes = new ConnectedMesh.Node[triangles.Length]; + Dictionary> vertexToNodes = new Dictionary>(); + for (int i = 0; i < triangles.Length; i += 3) + { + ConnectedMesh.Node A = new ConnectedMesh.Node(); + ConnectedMesh.Node B = new ConnectedMesh.Node(); + ConnectedMesh.Node C = new ConnectedMesh.Node(); + + A.position = triangles[i]; + B.position = triangles[i + 1]; + C.position = triangles[i + 2]; + + A.attribute = triangles[i]; + B.attribute = triangles[i + 1]; + C.attribute = triangles[i + 2]; + + A.relative = i + 1; // B + B.relative = i + 2; // C + C.relative = i; // A + + if (!vertexToNodes.ContainsKey(A.position)) + { + vertexToNodes.Add(A.position, new List()); + } + + if (!vertexToNodes.ContainsKey(B.position)) + { + vertexToNodes.Add(B.position, new List()); + } + + if (!vertexToNodes.ContainsKey(C.position)) + { + vertexToNodes.Add(C.position, new List()); + } + + vertexToNodes[A.position].Add(i); + vertexToNodes[B.position].Add(i + 1); + vertexToNodes[C.position].Add(i + 2); + + nodes[i] = A; + nodes[i + 1] = B; + nodes[i + 2] = C; + + connectedMesh._faceCount++; + } + + // Building siblings + foreach (KeyValuePair> pair in vertexToNodes) + { + int previousSibling = -1; + int firstSibling = -1; + foreach (int node in pair.Value) + { + if (firstSibling != -1) + { + nodes[node].sibling = previousSibling; + } + else + { + firstSibling = node; + } + previousSibling = node; + } + nodes[firstSibling].sibling = previousSibling; + } + + connectedMesh.nodes = nodes; + + Debug.Assert(connectedMesh.Check()); + + return connectedMesh; + } + } +} \ No newline at end of file diff --git a/LightlessSync/ThirdParty/Nanomesh/Todo.md b/LightlessSync/ThirdParty/Nanomesh/Todo.md new file mode 100644 index 0000000..b6312f1 --- /dev/null +++ b/LightlessSync/ThirdParty/Nanomesh/Todo.md @@ -0,0 +1,22 @@ +# Todo List NOT LIGHTLESS RELATED XD + +- [x] Bench iterating methods +- [x] Add a bunch of primitives +- [x] Add ConnectedMesh data structure +- [x] Add SharedMesh data structure + - [ ] Add vertex attributes +- [x] Add SharedMesh -> ConnectedMesh + - [ ] Add support for hardedges + - [ ] Add conversion of attributes +- [x] Add ConnectedMesh -> SharedMesh + - [ ] Add support for hardedges +- [x] Add export to obj + - [ ] Add support for normals +- [x] Add import from obj + - [ ] Add support for normals +- [x] Add decimate + - [x] Optimize until it is satisfying + - [ ] Take into account vertex normals + - [ ] Take into account borders + - [ ] Add an error target control +- [ ] Add create normals function \ No newline at end of file diff --git a/LightlessSync/UI/Components/OptimizationSettingsPanel.cs b/LightlessSync/UI/Components/OptimizationSettingsPanel.cs index 7b0477f..d8b8bd1 100644 --- a/LightlessSync/UI/Components/OptimizationSettingsPanel.cs +++ b/LightlessSync/UI/Components/OptimizationSettingsPanel.cs @@ -299,101 +299,12 @@ public sealed class OptimizationSettingsPanel DrawGroupHeader("Core Controls", UIColors.Get("LightlessOrange")); var performanceConfig = _performanceConfigService.Current; - using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) - using (var table = ImRaii.Table("model-opt-core", 3, SettingsTableFlags)) - { - if (table) - { - ImGui.TableSetupColumn("Label", ImGuiTableColumnFlags.WidthFixed, 220f * scale); - ImGui.TableSetupColumn("Control", ImGuiTableColumnFlags.WidthFixed, 180f * scale); - ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); - - DrawControlRow("Enable model decimation", () => - { - var enableDecimation = performanceConfig.EnableModelDecimation; - var accent = UIColors.Get("LightlessOrange"); - if (DrawAccentCheckbox("##enable-model-decimation", ref enableDecimation, accent)) - { - performanceConfig.EnableModelDecimation = enableDecimation; - _performanceConfigService.Save(); - } - }, "Generates a decimated copy of models after download.", UIColors.Get("LightlessOrange"), UIColors.Get("LightlessOrange")); - - DrawControlRow("Decimate above (triangles)", () => - { - var triangleThreshold = performanceConfig.ModelDecimationTriangleThreshold; - ImGui.SetNextItemWidth(-1f); - if (ImGui.SliderInt("##model-decimation-threshold", ref triangleThreshold, 1_000, 100_000)) - { - performanceConfig.ModelDecimationTriangleThreshold = Math.Clamp(triangleThreshold, 1_000, 100_000); - _performanceConfigService.Save(); - } - }, "Models below this triangle count are left untouched. Default: 15,000."); - - DrawControlRow("Target triangle ratio", () => - { - var targetPercent = (float)(performanceConfig.ModelDecimationTargetRatio * 100.0); - var clampedPercent = Math.Clamp(targetPercent, 60f, 99f); - if (Math.Abs(clampedPercent - targetPercent) > float.Epsilon) - { - performanceConfig.ModelDecimationTargetRatio = clampedPercent / 100.0; - _performanceConfigService.Save(); - targetPercent = clampedPercent; - } - - ImGui.SetNextItemWidth(-1f); - if (ImGui.SliderFloat("##model-decimation-target", ref targetPercent, 60f, 99f, "%.0f%%")) - { - performanceConfig.ModelDecimationTargetRatio = Math.Clamp(targetPercent / 100f, 0.6f, 0.99f); - _performanceConfigService.Save(); - } - }, "Ratio relative to original triangle count (80% keeps 80%). Default: 80%."); - } - } + DrawModelDecimationCard(performanceConfig); ImGui.Dummy(new Vector2(0f, 2f * scale)); DrawGroupHeader("Behavior & Exceptions", UIColors.Get("LightlessOrange")); - using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) - using (var table = ImRaii.Table("model-opt-behavior-table", 3, SettingsTableFlags)) - { - if (table) - { - ImGui.TableSetupColumn("Label", ImGuiTableColumnFlags.WidthFixed, 220f * scale); - ImGui.TableSetupColumn("Control", ImGuiTableColumnFlags.WidthFixed, 180f * scale); - ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); - - DrawControlRow("Normalize tangents", () => - { - var normalizeTangents = performanceConfig.ModelDecimationNormalizeTangents; - if (ImGui.Checkbox("##model-normalize-tangents", ref normalizeTangents)) - { - performanceConfig.ModelDecimationNormalizeTangents = normalizeTangents; - _performanceConfigService.Save(); - } - }, "Normalizes tangents to reduce shading artifacts."); - - DrawControlRow("Keep original model files", () => - { - var keepOriginalModels = performanceConfig.KeepOriginalModelFiles; - if (ImGui.Checkbox("##model-keep-original", ref keepOriginalModels)) - { - performanceConfig.KeepOriginalModelFiles = keepOriginalModels; - _performanceConfigService.Save(); - } - }, "Keeps the original model alongside the decimated copy."); - - DrawControlRow("Skip preferred/direct pairs", () => - { - var skipPreferredDecimation = performanceConfig.SkipModelDecimationForPreferredPairs; - if (ImGui.Checkbox("##model-skip-preferred", ref skipPreferredDecimation)) - { - performanceConfig.SkipModelDecimationForPreferredPairs = skipPreferredDecimation; - _performanceConfigService.Save(); - } - }, "Leaves models untouched for preferred/direct pairs."); - } - } + DrawModelBehaviorCard(performanceConfig); UiSharedService.ColorTextWrapped( "Note: Disabling \"Keep original model files\" prevents saved/effective triangle usage information.", @@ -436,6 +347,7 @@ public sealed class OptimizationSettingsPanel ImGui.TableSetupColumn("Control", ImGuiTableColumnFlags.WidthFixed, 180f * scale); ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + const string bodyDesc = "Body meshes (torso, limbs)."; DrawControlRow("Body", () => { var allowBody = config.ModelDecimationAllowBody; @@ -444,8 +356,15 @@ public sealed class OptimizationSettingsPanel config.ModelDecimationAllowBody = allowBody; _performanceConfigService.Save(); } - }, "Body meshes (torso, limbs)."); + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + config.ModelDecimationAllowBody = ModelDecimationDefaults.AllowBody; + _performanceConfigService.Save(); + } + UiSharedService.AttachToolTip($"{bodyDesc}\nRight-click to reset to default ({(ModelDecimationDefaults.AllowBody ? "On" : "Off")})."); + }, bodyDesc); + const string faceDesc = "Face and head meshes."; DrawControlRow("Face/head", () => { var allowFaceHead = config.ModelDecimationAllowFaceHead; @@ -454,8 +373,15 @@ public sealed class OptimizationSettingsPanel config.ModelDecimationAllowFaceHead = allowFaceHead; _performanceConfigService.Save(); } - }, "Face and head meshes."); + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + config.ModelDecimationAllowFaceHead = ModelDecimationDefaults.AllowFaceHead; + _performanceConfigService.Save(); + } + UiSharedService.AttachToolTip($"{faceDesc}\nRight-click to reset to default ({(ModelDecimationDefaults.AllowFaceHead ? "On" : "Off")})."); + }, faceDesc); + const string tailDesc = "Tail, ear, and similar appendages."; DrawControlRow("Tails/Ears", () => { var allowTail = config.ModelDecimationAllowTail; @@ -464,8 +390,15 @@ public sealed class OptimizationSettingsPanel config.ModelDecimationAllowTail = allowTail; _performanceConfigService.Save(); } - }, "Tail, ear, and similar appendages."); + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + config.ModelDecimationAllowTail = ModelDecimationDefaults.AllowTail; + _performanceConfigService.Save(); + } + UiSharedService.AttachToolTip($"{tailDesc}\nRight-click to reset to default ({(ModelDecimationDefaults.AllowTail ? "On" : "Off")})."); + }, tailDesc); + const string clothingDesc = "Outfits, shoes, gloves, hats."; DrawControlRow("Clothing", () => { var allowClothing = config.ModelDecimationAllowClothing; @@ -474,8 +407,15 @@ public sealed class OptimizationSettingsPanel config.ModelDecimationAllowClothing = allowClothing; _performanceConfigService.Save(); } - }, "Outfits, shoes, gloves, hats."); + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + config.ModelDecimationAllowClothing = ModelDecimationDefaults.AllowClothing; + _performanceConfigService.Save(); + } + UiSharedService.AttachToolTip($"{clothingDesc}\nRight-click to reset to default ({(ModelDecimationDefaults.AllowClothing ? "On" : "Off")})."); + }, clothingDesc); + const string accessoryDesc = "Jewelry and small add-ons."; DrawControlRow("Accessories", () => { var allowAccessories = config.ModelDecimationAllowAccessories; @@ -484,7 +424,13 @@ public sealed class OptimizationSettingsPanel config.ModelDecimationAllowAccessories = allowAccessories; _performanceConfigService.Save(); } - }, "Jewelry and small add-ons."); + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + config.ModelDecimationAllowAccessories = ModelDecimationDefaults.AllowAccessories; + _performanceConfigService.Save(); + } + UiSharedService.AttachToolTip($"{accessoryDesc}\nRight-click to reset to default ({(ModelDecimationDefaults.AllowAccessories ? "On" : "Off")})."); + }, accessoryDesc); } } @@ -592,6 +538,181 @@ public sealed class OptimizationSettingsPanel }); } + private void DrawModelDecimationCard(PlayerPerformanceConfig performanceConfig) + { + var scale = ImGuiHelpers.GlobalScale; + var accent = UIColors.Get("LightlessOrange"); + var bg = new Vector4(accent.X, accent.Y, accent.Z, 0.12f); + var border = new Vector4(accent.X, accent.Y, accent.Z, 0.32f); + const string enableDesc = "Generates a decimated copy of models after download."; + const string thresholdDesc = "Models below this triangle count are left untouched. Default: 15,000."; + const string ratioDesc = "Ratio relative to original triangle count (80% keeps 80%). Default: 80%."; + + DrawPanelBox("model-decimation-card", bg, border, 6f * scale, new Vector2(10f * scale, 6f * scale), () => + { + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + using (var table = ImRaii.Table("model-opt-core-card", 2, SettingsTableFlags)) + { + if (!table) + { + return; + } + + ImGui.TableSetupColumn("Label", ImGuiTableColumnFlags.WidthFixed, 220f * scale); + ImGui.TableSetupColumn("Control", ImGuiTableColumnFlags.WidthStretch); + + DrawInlineDescriptionRow("Enable model decimation", () => + { + var enableDecimation = performanceConfig.EnableModelDecimation; + if (DrawAccentCheckbox("##enable-model-decimation", ref enableDecimation, accent)) + { + performanceConfig.EnableModelDecimation = enableDecimation; + _performanceConfigService.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + performanceConfig.EnableModelDecimation = ModelDecimationDefaults.EnableAutoDecimation; + _performanceConfigService.Save(); + } + UiSharedService.AttachToolTip($"{enableDesc}\nRight-click to reset to default ({(ModelDecimationDefaults.EnableAutoDecimation ? "On" : "Off")})."); + }, enableDesc); + + DrawInlineDescriptionRow("Decimate above (triangles)", () => + { + var triangleThreshold = performanceConfig.ModelDecimationTriangleThreshold; + ImGui.SetNextItemWidth(220f * scale); + if (ImGui.SliderInt("##model-decimation-threshold", ref triangleThreshold, 1_000, 100_000)) + { + performanceConfig.ModelDecimationTriangleThreshold = Math.Clamp(triangleThreshold, 1_000, 100_000); + _performanceConfigService.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + performanceConfig.ModelDecimationTriangleThreshold = ModelDecimationDefaults.TriangleThreshold; + _performanceConfigService.Save(); + } + UiSharedService.AttachToolTip($"{thresholdDesc}\nRight-click to reset to default ({ModelDecimationDefaults.TriangleThreshold:N0})."); + }, thresholdDesc); + + DrawInlineDescriptionRow("Target triangle ratio", () => + { + var targetPercent = (float)(performanceConfig.ModelDecimationTargetRatio * 100.0); + var clampedPercent = Math.Clamp(targetPercent, 60f, 99f); + if (Math.Abs(clampedPercent - targetPercent) > float.Epsilon) + { + performanceConfig.ModelDecimationTargetRatio = clampedPercent / 100.0; + _performanceConfigService.Save(); + targetPercent = clampedPercent; + } + + ImGui.SetNextItemWidth(220f * scale); + if (ImGui.SliderFloat("##model-decimation-target", ref targetPercent, 60f, 99f, "%.0f%%")) + { + performanceConfig.ModelDecimationTargetRatio = Math.Clamp(targetPercent / 100f, 0.6f, 0.99f); + _performanceConfigService.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + performanceConfig.ModelDecimationTargetRatio = ModelDecimationDefaults.TargetRatio; + _performanceConfigService.Save(); + } + UiSharedService.AttachToolTip($"{ratioDesc}\nRight-click to reset to default ({ModelDecimationDefaults.TargetRatio * 100:0}%)."); + }, ratioDesc); + } + }); + } + + private void DrawModelBehaviorCard(PlayerPerformanceConfig performanceConfig) + { + var scale = ImGuiHelpers.GlobalScale; + var baseColor = UIColors.Get("LightlessGrey"); + var bg = new Vector4(baseColor.X, baseColor.Y, baseColor.Z, 0.12f); + var border = new Vector4(baseColor.X, baseColor.Y, baseColor.Z, 0.32f); + const string normalizeDesc = "Normalizes tangents to reduce shading artifacts."; + const string avoidBodyDesc = "Uses body materials as a collision guard to reduce clothing clipping. Slower and may reduce decimation."; + const string keepOriginalDesc = "Keeps the original model alongside the decimated copy."; + const string skipPreferredDesc = "Leaves models untouched for preferred/direct pairs."; + + DrawPanelBox("model-behavior-card", bg, border, 6f * scale, new Vector2(10f * scale, 6f * scale), () => + { + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + using (var table = ImRaii.Table("model-opt-behavior-card", 2, SettingsTableFlags)) + { + if (!table) + { + return; + } + + ImGui.TableSetupColumn("Label", ImGuiTableColumnFlags.WidthFixed, 220f * scale); + ImGui.TableSetupColumn("Control", ImGuiTableColumnFlags.WidthStretch); + + DrawInlineDescriptionRow("Normalize tangents", () => + { + var normalizeTangents = performanceConfig.ModelDecimationNormalizeTangents; + if (UiSharedService.CheckboxWithBorder("##model-normalize-tangents", ref normalizeTangents, baseColor)) + { + performanceConfig.ModelDecimationNormalizeTangents = normalizeTangents; + _performanceConfigService.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + performanceConfig.ModelDecimationNormalizeTangents = ModelDecimationDefaults.NormalizeTangents; + _performanceConfigService.Save(); + } + UiSharedService.AttachToolTip($"{normalizeDesc}\nRight-click to reset to default ({(ModelDecimationDefaults.NormalizeTangents ? "On" : "Off")})."); + }, normalizeDesc); + + DrawInlineDescriptionRow("Avoid body intersection", () => + { + var avoidBodyIntersection = performanceConfig.ModelDecimationAvoidBodyIntersection; + if (UiSharedService.CheckboxWithBorder("##model-body-collision", ref avoidBodyIntersection, baseColor)) + { + performanceConfig.ModelDecimationAvoidBodyIntersection = avoidBodyIntersection; + _performanceConfigService.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + performanceConfig.ModelDecimationAvoidBodyIntersection = ModelDecimationDefaults.AvoidBodyIntersection; + _performanceConfigService.Save(); + } + UiSharedService.AttachToolTip($"{avoidBodyDesc}\nRight-click to reset to default ({(ModelDecimationDefaults.AvoidBodyIntersection ? "On" : "Off")})."); + }, avoidBodyDesc); + + DrawInlineDescriptionRow("Keep original model files", () => + { + var keepOriginalModels = performanceConfig.KeepOriginalModelFiles; + if (UiSharedService.CheckboxWithBorder("##model-keep-original", ref keepOriginalModels, baseColor)) + { + performanceConfig.KeepOriginalModelFiles = keepOriginalModels; + _performanceConfigService.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + performanceConfig.KeepOriginalModelFiles = ModelDecimationDefaults.KeepOriginalModelFiles; + _performanceConfigService.Save(); + } + UiSharedService.AttachToolTip($"{keepOriginalDesc}\nRight-click to reset to default ({(ModelDecimationDefaults.KeepOriginalModelFiles ? "On" : "Off")})."); + }, keepOriginalDesc); + + DrawInlineDescriptionRow("Skip preferred/direct pairs", () => + { + var skipPreferredDecimation = performanceConfig.SkipModelDecimationForPreferredPairs; + if (UiSharedService.CheckboxWithBorder("##model-skip-preferred", ref skipPreferredDecimation, baseColor)) + { + performanceConfig.SkipModelDecimationForPreferredPairs = skipPreferredDecimation; + _performanceConfigService.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + performanceConfig.SkipModelDecimationForPreferredPairs = ModelDecimationDefaults.SkipPreferredPairs; + _performanceConfigService.Save(); + } + UiSharedService.AttachToolTip($"{skipPreferredDesc}\nRight-click to reset to default ({(ModelDecimationDefaults.SkipPreferredPairs ? "On" : "Off")})."); + }, skipPreferredDesc); + } + }); + } + private void DrawInlineDescriptionRow( string label, Action drawControl, diff --git a/LightlessSync/UI/Components/OptimizationSummaryCard.cs b/LightlessSync/UI/Components/OptimizationSummaryCard.cs index 62c0bc0..3ff4b23 100644 --- a/LightlessSync/UI/Components/OptimizationSummaryCard.cs +++ b/LightlessSync/UI/Components/OptimizationSummaryCard.cs @@ -535,6 +535,7 @@ public sealed class OptimizationSummaryCard new OptimizationTooltipLine("Triangle threshold", threshold), new OptimizationTooltipLine("Target ratio", targetRatio), new OptimizationTooltipLine("Normalize tangents", FormatOnOff(config.ModelDecimationNormalizeTangents), GetOnOffColor(config.ModelDecimationNormalizeTangents)), + new OptimizationTooltipLine("Avoid body intersection", FormatOnOff(config.ModelDecimationAvoidBodyIntersection), GetOnOffColor(config.ModelDecimationAvoidBodyIntersection)), new OptimizationTooltipLine("Keep original models", FormatOnOff(config.KeepOriginalModelFiles), GetOnOffColor(config.KeepOriginalModelFiles)), new OptimizationTooltipLine("Skip preferred pairs", FormatOnOff(config.SkipModelDecimationForPreferredPairs), GetOnOffColor(config.SkipModelDecimationForPreferredPairs)), new OptimizationTooltipLine("Targets", targetLabel, targetColor), diff --git a/LightlessSync/UI/DataAnalysisUi.cs b/LightlessSync/UI/DataAnalysisUi.cs index e0bfcb1..4ed7c30 100644 --- a/LightlessSync/UI/DataAnalysisUi.cs +++ b/LightlessSync/UI/DataAnalysisUi.cs @@ -8,13 +8,16 @@ using LightlessSync.API.Data.Enum; using LightlessSync.FileCache; using LightlessSync.Interop.Ipc; using LightlessSync.LightlessConfiguration; +using LightlessSync.LightlessConfiguration.Configurations; using LightlessSync.Services; using LightlessSync.Services.Mediator; +using LightlessSync.Services.ModelDecimation; using LightlessSync.Services.TextureCompression; using LightlessSync.UI.Models; using LightlessSync.Utils; using Microsoft.Extensions.Logging; using OtterTex; +using Penumbra.Api.Enums; using System.Buffers.Binary; using System.Globalization; using System.Numerics; @@ -34,9 +37,12 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private const float TextureFilterSplitterWidth = 8f; private const float TextureDetailSplitterWidth = 12f; private const float TextureDetailSplitterCollapsedWidth = 18f; + private const float ModelBatchSplitterHeight = 8f; private const float SelectedFilePanelLogicalHeight = 90f; private const float TextureHoverPreviewDelaySeconds = 1.75f; private const float TextureHoverPreviewSize = 350f; + private const float MinModelDetailPaneWidth = 520f; + private const float MaxModelDetailPaneWidth = 860f; private static readonly Vector4 SelectedTextureRowTextColor = new(0f, 0f, 0f, 1f); private readonly CharacterAnalyzer _characterAnalyzer; @@ -47,12 +53,14 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private readonly PlayerPerformanceConfigService _playerPerformanceConfig; private readonly TransientResourceManager _transientResourceManager; private readonly TransientConfigService _transientConfigService; + private readonly ModelDecimationService _modelDecimationService; private readonly TextureCompressionService _textureCompressionService; private readonly TextureMetadataHelper _textureMetadataHelper; private readonly List _textureRows = new(); private readonly Dictionary _textureSelections = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _selectedTextureKeys = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _selectedModelKeys = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _texturePreviews = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _textureResolutionCache = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _textureWorkspaceTabs = new(); @@ -61,20 +69,25 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private Dictionary>? _cachedAnalysis; private CancellationTokenSource _conversionCancellationTokenSource = new(); + private CancellationTokenSource _modelDecimationCts = new(); private CancellationTokenSource _transientRecordCts = new(); private Task? _conversionTask; + private Task? _modelDecimationTask; private TextureConversionProgress? _lastConversionProgress; private float _textureFilterPaneWidth = 320f; private float _textureDetailPaneWidth = 360f; private float _textureDetailHeight = 360f; private float _texturePreviewSize = 360f; + private float _modelDetailPaneWidth = 720f; + private float _modelBatchPanelHeight = 0f; private string _conversionCurrentFileName = string.Empty; private string _selectedFileTypeTab = string.Empty; private string _selectedHash = string.Empty; private string _textureSearch = string.Empty; + private string _modelSearch = string.Empty; private string _textureSlotFilter = "All"; private string _selectedTextureKey = string.Empty; private string _selectedStoredCharacter = string.Empty; @@ -85,6 +98,8 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private int _conversionCurrentFileProgress = 0; private int _conversionTotalJobs; + private int _modelDecimationCurrentProgress = 0; + private int _modelDecimationTotalJobs = 0; private bool _hasUpdate = false; private bool _modalOpen = false; @@ -92,6 +107,12 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private bool _textureRowsDirty = true; private bool _textureDetailCollapsed = false; private bool _conversionFailed; + private bool _modelDecimationFailed; + private bool _showModelBatchAdvancedSettings; + private bool _dismissedModelBatchWarning; + private bool _modelBatchWarningNeverShowPending; + private bool _modelBatchWarningPendingInitialized; + private string _modelDecimationCurrentHash = string.Empty; private double _textureHoverStartTime = 0; #if DEBUG private bool _debugCompressionModalOpen = false; @@ -115,8 +136,8 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase PerformanceCollectorService performanceCollectorService, UiSharedService uiSharedService, LightlessConfigService configService, PlayerPerformanceConfigService playerPerformanceConfig, TransientResourceManager transientResourceManager, - TransientConfigService transientConfigService, TextureCompressionService textureCompressionService, - TextureMetadataHelper textureMetadataHelper) + TransientConfigService transientConfigService, ModelDecimationService modelDecimationService, + TextureCompressionService textureCompressionService, TextureMetadataHelper textureMetadataHelper) : base(logger, mediator, "Lightless Character Data Analysis", performanceCollectorService) { _characterAnalyzer = characterAnalyzer; @@ -126,6 +147,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase _playerPerformanceConfig = playerPerformanceConfig; _transientResourceManager = transientResourceManager; _transientConfigService = transientConfigService; + _modelDecimationService = modelDecimationService; _textureCompressionService = textureCompressionService; _textureMetadataHelper = textureMetadataHelper; Mediator.Subscribe(this, (_) => @@ -428,6 +450,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase _cachedAnalysis = CloneAnalysis(_characterAnalyzer.LastAnalysis); _hasUpdate = false; InvalidateTextureRows(); + PruneModelSelections(); } private void DrawContentTabs() @@ -943,8 +966,10 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase _selectedFileTypeTab = string.Empty; } + var modelGroup = groupedfiles.FirstOrDefault(g => string.Equals(g.Key, "mdl", StringComparison.OrdinalIgnoreCase)); var otherFileGroups = groupedfiles - .Where(g => !string.Equals(g.Key, "tex", StringComparison.Ordinal)) + .Where(g => !string.Equals(g.Key, "tex", StringComparison.OrdinalIgnoreCase) + && !string.Equals(g.Key, "mdl", StringComparison.OrdinalIgnoreCase)) .ToList(); if (!string.IsNullOrEmpty(_selectedFileTypeTab) && @@ -958,7 +983,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase _selectedFileTypeTab = otherFileGroups[0].Key; } - DrawTextureWorkspace(kvp.Key, otherFileGroups); + DrawTextureWorkspace(kvp.Key, modelGroup, otherFileGroups); } } } @@ -970,9 +995,11 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase _selectedTextureKey = string.Empty; _selectedTextureKeys.Clear(); _textureSelections.Clear(); + _selectedModelKeys.Clear(); ResetTextureFilters(); InvalidateTextureRows(); _conversionFailed = false; + _modelDecimationFailed = false; #if DEBUG ResetDebugCompressionModalState(); #endif @@ -996,6 +1023,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase _textureRowsBuildCts?.Cancel(); _textureRowsBuildCts?.Dispose(); _conversionProgress.ProgressChanged -= ConversionProgress_ProgressChanged; + _modelDecimationCts.CancelDispose(); } private void ConversionProgress_ProgressChanged(object? sender, TextureConversionProgress e) @@ -1097,6 +1125,22 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase } } + private void PruneModelSelections() + { + if (_cachedAnalysis == null || _selectedModelKeys.Count == 0) + { + return; + } + + var validKeys = _cachedAnalysis.Values + .SelectMany(entries => entries.Values) + .Where(entry => string.Equals(entry.FileType, "mdl", StringComparison.OrdinalIgnoreCase)) + .Select(entry => entry.Hash) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + _selectedModelKeys.RemoveWhere(key => !validKeys.Contains(key)); + } + private TextureRowBuildResult BuildTextureRows( Dictionary> analysis, CancellationToken token) @@ -1390,6 +1434,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private enum TextureWorkspaceTab { Textures, + Models, OtherFiles } @@ -1445,14 +1490,19 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase public ushort MipLevels { get; } } - private void DrawTextureWorkspace(ObjectKind objectKind, IReadOnlyList> otherFileGroups) + private void DrawTextureWorkspace( + ObjectKind objectKind, + IGrouping? modelGroup, + IReadOnlyList> otherFileGroups) { if (!_textureWorkspaceTabs.ContainsKey(objectKind)) { _textureWorkspaceTabs[objectKind] = TextureWorkspaceTab.Textures; } - if (otherFileGroups.Count == 0) + var hasModels = modelGroup != null; + var hasOther = otherFileGroups.Count > 0; + if (!hasModels && !hasOther) { _textureWorkspaceTabs[objectKind] = TextureWorkspaceTab.Textures; DrawTextureTabContent(objectKind); @@ -1473,8 +1523,22 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase } } - using (var otherFilesTab = ImRaii.TabItem($"Other file types###other_{objectKind}")) + if (hasModels && modelGroup != null) { + using var modelsTab = ImRaii.TabItem($"Models###models_{objectKind}"); + if (modelsTab) + { + if (_textureWorkspaceTabs[objectKind] != TextureWorkspaceTab.Models) + { + _textureWorkspaceTabs[objectKind] = TextureWorkspaceTab.Models; + } + DrawModelWorkspace(modelGroup); + } + } + + if (hasOther) + { + using var otherFilesTab = ImRaii.TabItem($"Other file types###other_{objectKind}"); if (otherFilesTab) { if (_textureWorkspaceTabs[objectKind] != TextureWorkspaceTab.OtherFiles) @@ -1898,6 +1962,249 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase } } + private static void DrawPanelBox(string id, Vector4 background, Vector4 border, float rounding, Vector2 padding, Action content) + { + using (ImRaii.PushId(id)) + { + var startPos = ImGui.GetCursorScreenPos(); + var availableWidth = ImGui.GetContentRegionAvail().X; + var drawList = ImGui.GetWindowDrawList(); + + drawList.ChannelsSplit(2); + drawList.ChannelsSetCurrent(1); + + using (ImRaii.Group()) + { + ImGui.Dummy(new Vector2(0f, padding.Y)); + ImGui.Indent(padding.X); + content(); + ImGui.Unindent(padding.X); + ImGui.Dummy(new Vector2(0f, padding.Y)); + } + + var rectMin = startPos; + var rectMax = new Vector2(startPos.X + availableWidth, ImGui.GetItemRectMax().Y); + var borderThickness = MathF.Max(1f, ImGui.GetStyle().ChildBorderSize); + + drawList.ChannelsSetCurrent(0); + drawList.AddRectFilled(rectMin, rectMax, UiSharedService.Color(background), rounding); + drawList.AddRect(rectMin, rectMax, UiSharedService.Color(border), rounding, ImDrawFlags.None, borderThickness); + drawList.ChannelsMerge(); + } + } + + private void DrawModelWorkspace(IGrouping modelGroup) + { + var scale = ImGuiHelpers.GlobalScale; + ImGuiHelpers.ScaledDummy(0); + var accent = UIColors.Get("LightlessBlue"); + var baseItemSpacing = ImGui.GetStyle().ItemSpacing; + var warningAccent = UIColors.Get("LightlessOrange"); + var config = _playerPerformanceConfig.Current; + var showWarning = !_dismissedModelBatchWarning && config.ShowBatchModelDecimationWarning; + var sectionAvail = ImGui.GetContentRegionAvail().Y; + var childHeight = MathF.Max(0f, sectionAvail - 2f * scale); + var warningRectValid = false; + + using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize))) + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(4f * scale, 2f * scale))) + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(12f * scale, 4f * scale))) + using (var child = ImRaii.Child("modelFiles", new Vector2(-1f, childHeight), false)) + { + if (child) + { + warningRectValid = true; + using (ImRaii.Disabled(showWarning)) + { + var originalTotal = modelGroup.Sum(c => c.OriginalSize); + var compressedTotal = modelGroup.Sum(c => c.CompressedSize); + var triangleTotal = modelGroup.Sum(c => c.Triangles); + + var availableWidth = ImGui.GetContentRegionAvail().X; + var splitSpacingX = 4f * scale; + var spacingX = splitSpacingX; + var minDetailWidth = MinModelDetailPaneWidth * scale; + var maxDetailWidth = Math.Min(MaxModelDetailPaneWidth * scale, Math.Max(minDetailWidth, availableWidth - (360f * scale) - spacingX)); + var minTableWidth = 360f * scale; + + var detailWidth = Math.Clamp(_modelDetailPaneWidth, minDetailWidth, maxDetailWidth); + var tableWidth = availableWidth - detailWidth - spacingX; + if (tableWidth < minTableWidth) + { + detailWidth = Math.Max(0f, availableWidth - minTableWidth - spacingX); + tableWidth = availableWidth - detailWidth - spacingX; + if (tableWidth <= 0f) + { + tableWidth = availableWidth; + detailWidth = 0f; + } + } + if (detailWidth > 0f) + { + _modelDetailPaneWidth = detailWidth; + } + + ImGui.BeginGroup(); + using (var leftChild = ImRaii.Child("modelMainPane", new Vector2(detailWidth > 0f ? tableWidth : -1f, 0f), false)) + { + if (leftChild) + { + var badgeBg = new Vector4(accent.X, accent.Y, accent.Z, 0.18f); + var badgeBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.35f); + var summaryHeight = MathF.Max(ImGui.GetTextLineHeightWithSpacing() * 2.6f, 36f * scale); + var summaryWidth = MathF.Min(520f * scale, ImGui.GetContentRegionAvail().X); + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 4f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize))) + using (ImRaii.PushColor(ImGuiCol.ChildBg, UiSharedService.Color(badgeBg))) + using (ImRaii.PushColor(ImGuiCol.Border, UiSharedService.Color(badgeBorder))) + using (var summaryChild = ImRaii.Child("modelSummary", new Vector2(summaryWidth, summaryHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) + { + if (summaryChild) + { + var infoColor = ImGuiColors.DalamudGrey; + var countColor = UIColors.Get("LightlessBlue"); + var actualColor = ImGuiColors.DalamudGrey; + var compressedColor = UIColors.Get("LightlessYellow2"); + var triColor = UIColors.Get("LightlessPurple"); + + using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(10f * scale, 4f * scale))) + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(12f * scale, 2f * scale))) + using (var summaryTable = ImRaii.Table("modelSummaryTable", 4, + ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.PadOuterX | ImGuiTableFlags.NoHostExtendX, + new Vector2(-1f, -1f))) + { + if (summaryTable) + { + ImGui.TableNextRow(); + DrawSummaryCell(FontAwesomeIcon.LayerGroup, countColor, + modelGroup.Count().ToString("N0", CultureInfo.InvariantCulture), + "Model files", infoColor, scale); + DrawSummaryCell(FontAwesomeIcon.FileArchive, actualColor, + UiSharedService.ByteToString(originalTotal), + "Actual size", infoColor, scale); + DrawSummaryCell(FontAwesomeIcon.CompressArrowsAlt, compressedColor, + UiSharedService.ByteToString(compressedTotal), + "Compressed size", infoColor, scale); + DrawSummaryCell(FontAwesomeIcon.ProjectDiagram, triColor, + triangleTotal.ToString("N0", CultureInfo.InvariantCulture), + "Triangles", infoColor, scale); + } + } + } + } + + if (_showModelBatchAdvancedSettings) + { + var splitterHeight = ModelBatchSplitterHeight * scale; + var minBatchHeight = 140f * scale; + var minTableHeight = 180f * scale; + var availableHeight = ImGui.GetContentRegionAvail().Y; + var decimationRunning = _modelDecimationTask != null && !_modelDecimationTask.IsCompleted; + var actionsHeight = ImGui.GetFrameHeightWithSpacing(); + if (decimationRunning) + { + actionsHeight += ImGui.GetFrameHeightWithSpacing(); + } + + var maxBatchHeight = Math.Max(minBatchHeight, availableHeight - minTableHeight - splitterHeight - actionsHeight); + if (_modelBatchPanelHeight <= 0f || _modelBatchPanelHeight > maxBatchHeight) + { + _modelBatchPanelHeight = Math.Min( + maxBatchHeight, + Math.Max(minBatchHeight, (availableHeight - actionsHeight) * 0.35f)); + } + + using (var batchChild = ImRaii.Child("modelBatchArea", new Vector2(-1f, _modelBatchPanelHeight), false)) + { + if (batchChild) + { + DrawModelBatchPanel(); + } + } + + DrawHorizontalResizeHandle("##modelBatchSplitter", ref _modelBatchPanelHeight, minBatchHeight, maxBatchHeight, out _); + + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, baseItemSpacing)) + { + DrawModelBatchActions(); + } + } + else + { + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, baseItemSpacing)) + { + DrawModelBatchPanel(); + DrawModelBatchActions(); + } + } + + using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(4f * scale, 4f * scale))) + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(18f * scale, 4f * scale))) + using (ImRaii.PushStyle(ImGuiStyleVar.FramePadding, new Vector2(4f * scale, 3f * scale))) + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 3f * scale))) + { + DrawTable(modelGroup); + } + } + } + ImGui.EndGroup(); + + if (detailWidth > 0f) + { + var leftMin = ImGui.GetItemRectMin(); + var leftMax = ImGui.GetItemRectMax(); + var leftHeight = leftMax.Y - leftMin.Y; + var leftTopLocal = leftMin - ImGui.GetWindowPos(); + var maxDetailResize = Math.Min(MaxModelDetailPaneWidth * scale, Math.Max(minDetailWidth, availableWidth - minTableWidth - spacingX)); + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(splitSpacingX, ImGui.GetStyle().ItemSpacing.Y))) + { + DrawVerticalResizeHandle( + "##modelDetailSplitter", + leftTopLocal.Y, + leftHeight, + ref _modelDetailPaneWidth, + minDetailWidth, + maxDetailResize, + out _, + invert: true, + splitterWidthOverride: TextureDetailSplitterWidth); + } + + ImGui.BeginGroup(); + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 5f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize))) + using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(4f * scale, 4f * scale))) + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(12f * scale, 4f * scale))) + using (var detailChild = ImRaii.Child("modelDetailPane", new Vector2(detailWidth, 0f), true)) + { + if (detailChild) + { + DrawModelDetailPane(modelGroup); + } + } + ImGui.EndGroup(); + } + } + } + } + + if (showWarning && warningRectValid) + { + if (!_modelBatchWarningPendingInitialized) + { + _modelBatchWarningNeverShowPending = !config.ShowBatchModelDecimationWarning; + _modelBatchWarningPendingInitialized = true; + } + + DrawModelBatchWarningOverlay(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), config, warningAccent); + } + else + { + _modelBatchWarningPendingInitialized = false; + } + } + private void DrawOtherFileWorkspace(IReadOnlyList> otherFileGroups) { if (otherFileGroups.Count == 0) @@ -2019,6 +2326,1008 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase DrawSelectedFileDetails(activeGroup); } + private void DrawModelBatchPanel() + { + var scale = ImGuiHelpers.GlobalScale; + var config = _playerPerformanceConfig.Current; + var accent = UIColors.Get("LightlessOrange"); + var panelBg = new Vector4(accent.X, accent.Y, accent.Z, 0.12f); + var panelBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.35f); + + DrawPanelBox("model-batch-panel", panelBg, panelBorder, 6f * scale, new Vector2(10f * scale, 6f * scale), () => + { + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(12f * scale, 4f * scale))) + { + _uiSharedService.IconText(FontAwesomeIcon.ProjectDiagram, accent); + ImGui.SameLine(0f, 6f * scale); + UiSharedService.ColorText("Batch decimation", accent); + } + + UiSharedService.TextWrapped("Mark models in the table to add them to the decimation queue. Settings here apply only to batch decimation."); + + if (_modelDecimationFailed) + { + UiSharedService.ColorTextWrapped("Model decimation failed. Check logs for details.", UIColors.Get("DimRed")); + } + + ImGuiHelpers.ScaledDummy(4); + + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + using (var settingsTable = ImRaii.Table("modelBatchSettings", 2, + ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.NoBordersInBody)) + { + if (settingsTable) + { + ImGui.TableSetupColumn("Label", ImGuiTableColumnFlags.WidthFixed, 220f * scale); + ImGui.TableSetupColumn("Control", ImGuiTableColumnFlags.WidthStretch); + + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Target triangle ratio"); + var defaultTargetPercent = (float)(ModelDecimationDefaults.BatchTargetRatio * 100.0); + UiSharedService.AttachToolTip($"Percentage of triangles to keep after decimation. Default: {defaultTargetPercent:0}%.\nRight-click to reset."); + + ImGui.TableSetColumnIndex(1); + var targetPercent = (float)(config.BatchModelDecimationTargetRatio * 100.0); + var clampedPercent = Math.Clamp(targetPercent, 1f, 99f); + if (Math.Abs(clampedPercent - targetPercent) > float.Epsilon) + { + config.BatchModelDecimationTargetRatio = clampedPercent / 100.0; + _playerPerformanceConfig.Save(); + targetPercent = clampedPercent; + } + + ImGui.SetNextItemWidth(220f * scale); + if (ImGui.SliderFloat("##batch-decimation-target", ref targetPercent, 1f, 99f, "%.0f%%")) + { + config.BatchModelDecimationTargetRatio = Math.Clamp(targetPercent / 100f, 0.01f, 0.99f); + _playerPerformanceConfig.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + config.BatchModelDecimationTargetRatio = ModelDecimationDefaults.BatchTargetRatio; + _playerPerformanceConfig.Save(); + } + + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Normalize tangents"); + UiSharedService.AttachToolTip($"Normalize tangent vectors after decimation. Default: {(ModelDecimationDefaults.BatchNormalizeTangents ? "On" : "Off")}.\nRight-click to reset."); + + ImGui.TableSetColumnIndex(1); + var normalizeTangents = config.BatchModelDecimationNormalizeTangents; + if (UiSharedService.CheckboxWithBorder("##batch-decimation-normalize", ref normalizeTangents, accent, 1.5f)) + { + config.BatchModelDecimationNormalizeTangents = normalizeTangents; + _playerPerformanceConfig.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + config.BatchModelDecimationNormalizeTangents = ModelDecimationDefaults.BatchNormalizeTangents; + _playerPerformanceConfig.Save(); + } + + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Avoid body intersection"); + UiSharedService.AttachToolTip($"Uses body materials as a collision guard to reduce clothing clipping. Slower and may reduce decimation. Default: {(ModelDecimationDefaults.BatchAvoidBodyIntersection ? "On" : "Off")}.\nRight-click to reset."); + + ImGui.TableSetColumnIndex(1); + var avoidBodyIntersection = config.BatchModelDecimationAvoidBodyIntersection; + if (UiSharedService.CheckboxWithBorder("##batch-decimation-body-collision", ref avoidBodyIntersection, accent, 1.5f)) + { + config.BatchModelDecimationAvoidBodyIntersection = avoidBodyIntersection; + _playerPerformanceConfig.Save(); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + config.BatchModelDecimationAvoidBodyIntersection = ModelDecimationDefaults.BatchAvoidBodyIntersection; + _playerPerformanceConfig.Save(); + } + } + } + + ImGuiHelpers.ScaledDummy(4); + var showAdvanced = _showModelBatchAdvancedSettings; + if (UiSharedService.CheckboxWithBorder("##batch-decimation-advanced-toggle", ref showAdvanced, accent, 1.5f)) + { + _showModelBatchAdvancedSettings = showAdvanced; + } + + ImGui.SameLine(0f, 6f * scale); + ImGui.TextUnformatted("Advanced settings"); + ImGuiHelpers.ScaledDummy(2); + UiSharedService.ColorTextWrapped("Applies to automatic and batch decimation.", UIColors.Get("LightlessGrey")); + + if (_showModelBatchAdvancedSettings) + { + ImGuiHelpers.ScaledDummy(4); + DrawModelBatchAdvancedSettings(config, accent); + } + + ImGuiHelpers.ScaledDummy(4); + }); + } + + private void DrawModelBatchWarningOverlay(Vector2 panelMin, Vector2 panelMax, PlayerPerformanceConfig config, Vector4 accent) + { + var scale = ImGuiHelpers.GlobalScale; + var overlaySize = panelMax - panelMin; + + if (overlaySize.X <= 0f || overlaySize.Y <= 0f) + { + return; + } + + var previousCursor = ImGui.GetCursorPos(); + var windowPos = ImGui.GetWindowPos(); + ImGui.SetCursorPos(panelMin - windowPos); + + var bgColor = ImGui.GetStyle().Colors[(int)ImGuiCol.WindowBg]; + bgColor.W = 0.9f; + + ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.ChildBorderSize, 0f); + ImGui.PushStyleColor(ImGuiCol.Border, Vector4.Zero); + ImGui.PushStyleColor(ImGuiCol.ChildBg, bgColor); + + var overlayFlags = ImGuiWindowFlags.NoScrollbar + | ImGuiWindowFlags.NoScrollWithMouse + | ImGuiWindowFlags.NoSavedSettings; + + if (ImGui.BeginChild("##model_decimation_warning_overlay", overlaySize, false, overlayFlags)) + { + var contentMin = ImGui.GetWindowContentRegionMin(); + var contentMax = ImGui.GetWindowContentRegionMax(); + var contentSize = contentMax - contentMin; + var text = "Model decimation is a destructive process but the algorithm was built with multiple safety features to avoid damage to the mesh and prevent clipping.\nIt is advised to back up your important mods or models/meshes before running decimation as it's not recoverable."; + var cardWidth = MathF.Min(520f * scale, contentSize.X - (32f * scale)); + cardWidth = MathF.Max(cardWidth, 320f * scale); + var cardPadding = new Vector2(12f * scale, 10f * scale); + var wrapWidth = cardWidth - (cardPadding.X * 2f); + var textSize = ImGui.CalcTextSize(text, false, wrapWidth); + var headerHeight = ImGui.GetTextLineHeightWithSpacing(); + var rowHeight = MathF.Max(ImGui.GetFrameHeight(), headerHeight); + var buttonHeight = ImGui.GetFrameHeight(); + var mediumGap = 6f * scale; + var headerGap = 4f * scale; + var cardHeight = (cardPadding.Y * 2f) + + headerHeight + + headerGap + + textSize.Y + + mediumGap + + rowHeight + + mediumGap + + buttonHeight; + + var cardMin = new Vector2( + contentMin.X + Math.Max(0f, (contentSize.X - cardWidth) * 0.5f), + contentMin.Y + Math.Max(0f, (contentSize.Y - cardHeight) * 0.5f)); + var cardMax = cardMin + new Vector2(cardWidth, cardHeight); + var cardMinScreen = ImGui.GetWindowPos() + cardMin; + var cardMaxScreen = ImGui.GetWindowPos() + cardMax; + + var drawList = ImGui.GetWindowDrawList(); + var cardBg = new Vector4(accent.X, accent.Y, accent.Z, 0.24f); + var cardBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.6f); + drawList.AddRectFilled(cardMinScreen, cardMaxScreen, UiSharedService.Color(cardBg), 6f * scale); + drawList.AddRect(cardMinScreen, cardMaxScreen, UiSharedService.Color(cardBorder), 6f * scale); + + var baseX = cardMin.X + cardPadding.X; + var currentY = cardMin.Y + cardPadding.Y; + + ImGui.SetCursorPos(new Vector2(baseX, currentY)); + var warningColor = UIColors.Get("LightlessYellow"); + _uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, warningColor); + ImGui.SameLine(0f, 6f * scale); + UiSharedService.ColorText("Model Decimation", warningColor); + + currentY += headerHeight + headerGap; + ImGui.SetCursorPos(new Vector2(baseX, currentY)); + ImGui.PushTextWrapPos(baseX + wrapWidth); + ImGui.TextUnformatted(text); + ImGui.PopTextWrapPos(); + + currentY += textSize.Y + mediumGap; + ImGui.SetCursorPos(new Vector2(baseX, currentY)); + + var neverShowAgain = _modelBatchWarningNeverShowPending; + if (UiSharedService.CheckboxWithBorder("##batch-decimation-warning-never", ref neverShowAgain, accent, 1.5f)) + { + _modelBatchWarningNeverShowPending = neverShowAgain; + } + ImGui.SameLine(0f, 6f * scale); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Never show again"); + + currentY += rowHeight + mediumGap; + var buttonWidth = 200f * scale; + var buttonX = cardMin.X + Math.Max(0f, (cardWidth - buttonWidth) * 0.5f); + ImGui.SetCursorPos(new Vector2(buttonX, currentY)); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Check, "I understand", buttonWidth, center: true)) + { + config.ShowBatchModelDecimationWarning = !_modelBatchWarningNeverShowPending; + _playerPerformanceConfig.Save(); + _dismissedModelBatchWarning = true; + } + } + + ImGui.EndChild(); + ImGui.PopStyleColor(2); + ImGui.PopStyleVar(2); + ImGui.SetCursorPos(previousCursor); + } + + private void DrawModelBatchAdvancedSettings(PlayerPerformanceConfig config, Vector4 accent) + { + var scale = ImGuiHelpers.GlobalScale; + var advanced = config.ModelDecimationAdvanced; + var labelWidth = 190f * scale; + var itemWidth = -1f; + + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + using (var table = ImRaii.Table("modelBatchAdvancedSettings", 4, ImGuiTableFlags.SizingStretchSame | ImGuiTableFlags.NoBordersInBody)) + { + if (!table) + { + return; + } + + ImGui.TableSetupColumn("LabelLeft", ImGuiTableColumnFlags.WidthFixed, labelWidth); + ImGui.TableSetupColumn("ControlLeft", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("LabelRight", ImGuiTableColumnFlags.WidthFixed, labelWidth); + ImGui.TableSetupColumn("ControlRight", ImGuiTableColumnFlags.WidthStretch); + + var triangleThreshold = config.BatchModelDecimationTriangleThreshold; + DrawBatchAdvancedCategoryRow( + "Component limits", + "Limits that decide which meshes or components are eligible for batch decimation.", + scale); + ImGui.TableNextRow(); + if (DrawBatchAdvancedIntCell( + "Triangle threshold", + "batch-adv-triangle-threshold", + triangleThreshold, + 0, + 100_000, + ModelDecimationDefaults.BatchTriangleThreshold, + "Skip meshes below this triangle count during batch decimation (0 disables).", + itemWidth, + out var triangleThresholdValue)) + { + config.BatchModelDecimationTriangleThreshold = triangleThresholdValue; + _playerPerformanceConfig.Save(); + } + + var minComponentTriangles = advanced.MinComponentTriangles; + if (DrawBatchAdvancedIntCell( + "Min component triangles", + "batch-adv-min-component", + minComponentTriangles, + 0, + 200, + ModelDecimationAdvancedSettings.DefaultMinComponentTriangles, + "Components at or below this triangle count are left untouched.", + itemWidth, + out var minComponentTrianglesValue)) + { + advanced.MinComponentTriangles = minComponentTrianglesValue; + _playerPerformanceConfig.Save(); + } + + var maxEdgeFactor = advanced.MaxCollapseEdgeLengthFactor; + DrawBatchAdvancedCategoryRow( + "Collapse guards", + "Quality and topology guards that block unsafe edge collapses.", + scale); + ImGui.TableNextRow(); + if (DrawBatchAdvancedFloatCell( + "Max edge length factor", + "batch-adv-max-edge", + maxEdgeFactor, + 0.1f, + 5f, + 0.01f, + "%.2f", + ModelDecimationAdvancedSettings.DefaultMaxCollapseEdgeLengthFactor, + "Caps collapses to (average edge length * factor).", + itemWidth, + out var maxEdgeFactorValue)) + { + advanced.MaxCollapseEdgeLengthFactor = maxEdgeFactorValue; + _playerPerformanceConfig.Save(); + } + + var normalSimilarity = advanced.NormalSimilarityThresholdDegrees; + if (DrawBatchAdvancedFloatCell( + "Normal similarity (deg)", + "batch-adv-normal-sim", + normalSimilarity, + 0f, + 180f, + 1f, + "%.0f", + ModelDecimationAdvancedSettings.DefaultNormalSimilarityThresholdDegrees, + "Block collapses that bend normals beyond this angle.", + itemWidth, + out var normalSimilarityValue)) + { + advanced.NormalSimilarityThresholdDegrees = normalSimilarityValue; + _playerPerformanceConfig.Save(); + } + + var boneWeightSimilarity = advanced.BoneWeightSimilarityThreshold; + ImGui.TableNextRow(); + if (DrawBatchAdvancedFloatCell( + "Bone weight similarity", + "batch-adv-bone-sim", + boneWeightSimilarity, + 0f, + 1f, + 0.01f, + "%.2f", + ModelDecimationAdvancedSettings.DefaultBoneWeightSimilarityThreshold, + "Requires this bone-weight overlap to allow a collapse.", + itemWidth, + out var boneWeightSimilarityValue)) + { + advanced.BoneWeightSimilarityThreshold = boneWeightSimilarityValue; + _playerPerformanceConfig.Save(); + } + + var uvSimilarity = advanced.UvSimilarityThreshold; + if (DrawBatchAdvancedFloatCell( + "UV similarity threshold", + "batch-adv-uv-sim", + uvSimilarity, + 0f, + 0.5f, + 0.005f, + "%.3f", + ModelDecimationAdvancedSettings.DefaultUvSimilarityThreshold, + "Blocks collapses when UVs diverge beyond this threshold.", + itemWidth, + out var uvSimilarityValue)) + { + advanced.UvSimilarityThreshold = uvSimilarityValue; + _playerPerformanceConfig.Save(); + } + + var uvSeamCos = advanced.UvSeamAngleCos; + ImGui.TableNextRow(); + if (DrawBatchAdvancedFloatCell( + "UV seam cosine", + "batch-adv-uv-seam-cos", + uvSeamCos, + -1f, + 1f, + 0.01f, + "%.2f", + ModelDecimationAdvancedSettings.DefaultUvSeamAngleCos, + "Cosine threshold for UV seam detection (higher is stricter).", + itemWidth, + out var uvSeamCosValue)) + { + advanced.UvSeamAngleCos = uvSeamCosValue; + _playerPerformanceConfig.Save(); + } + + var blockUvSeams = advanced.BlockUvSeamVertices; + if (DrawBatchAdvancedBoolCell( + "Block UV seam vertices", + "batch-adv-uv-block", + blockUvSeams, + ModelDecimationAdvancedSettings.DefaultBlockUvSeamVertices, + "Prevent collapses across UV seams.", + accent, + out var blockUvSeamsValue)) + { + advanced.BlockUvSeamVertices = blockUvSeamsValue; + _playerPerformanceConfig.Save(); + } + + var allowBoundary = advanced.AllowBoundaryCollapses; + ImGui.TableNextRow(); + if (DrawBatchAdvancedBoolCell( + "Allow boundary collapses", + "batch-adv-boundary", + allowBoundary, + ModelDecimationAdvancedSettings.DefaultAllowBoundaryCollapses, + "Allow collapses on mesh boundaries (can create holes).", + accent, + out var allowBoundaryValue)) + { + advanced.AllowBoundaryCollapses = allowBoundaryValue; + _playerPerformanceConfig.Save(); + } + + var bodyDistance = advanced.BodyCollisionDistanceFactor; + DrawBatchAdvancedEmptyCell(); + + var bodyNoOpDistance = advanced.BodyCollisionNoOpDistanceFactor; + DrawBatchAdvancedCategoryRow( + "Body collision", + "Controls how the body mesh is used as a collision guard to reduce clothing clipping.", + scale); + ImGui.TableNextRow(); + if (DrawBatchAdvancedFloatCell( + "Body collision distance", + "batch-adv-body-distance", + bodyDistance, + 0f, + 5f, + 0.01f, + "%.2f", + ModelDecimationAdvancedSettings.DefaultBodyCollisionDistanceFactor, + "Primary body collision distance factor.", + itemWidth, + out var bodyDistanceValue)) + { + advanced.BodyCollisionDistanceFactor = bodyDistanceValue; + _playerPerformanceConfig.Save(); + } + + if (DrawBatchAdvancedFloatCell( + "Body collision fallback distance", + "batch-adv-body-noop", + bodyNoOpDistance, + 0f, + 5f, + 0.01f, + "%.2f", + ModelDecimationAdvancedSettings.DefaultBodyCollisionNoOpDistanceFactor, + "Fallback body collision distance for relaxed pass.", + itemWidth, + out var bodyNoOpDistanceValue)) + { + advanced.BodyCollisionNoOpDistanceFactor = bodyNoOpDistanceValue; + _playerPerformanceConfig.Save(); + } + + var bodyRelax = advanced.BodyCollisionAdaptiveRelaxFactor; + var bodyNearRatio = advanced.BodyCollisionAdaptiveNearRatio; + ImGui.TableNextRow(); + if (DrawBatchAdvancedFloatCell( + "Body collision relax factor", + "batch-adv-body-relax", + bodyRelax, + 0f, + 5f, + 0.01f, + "%.2f", + ModelDecimationAdvancedSettings.DefaultBodyCollisionAdaptiveRelaxFactor, + "Multiplier applied when the mesh is near the body.", + itemWidth, + out var bodyRelaxValue)) + { + advanced.BodyCollisionAdaptiveRelaxFactor = bodyRelaxValue; + _playerPerformanceConfig.Save(); + } + + if (DrawBatchAdvancedFloatCell( + "Body collision near ratio", + "batch-adv-body-near", + bodyNearRatio, + 0f, + 1f, + 0.01f, + "%.2f", + ModelDecimationAdvancedSettings.DefaultBodyCollisionAdaptiveNearRatio, + "Fraction of vertices near the body required to relax.", + itemWidth, + out var bodyNearRatioValue)) + { + advanced.BodyCollisionAdaptiveNearRatio = bodyNearRatioValue; + _playerPerformanceConfig.Save(); + } + + var bodyUvRelax = advanced.BodyCollisionAdaptiveUvThreshold; + var bodyNoOpUvCos = advanced.BodyCollisionNoOpUvSeamAngleCos; + ImGui.TableNextRow(); + if (DrawBatchAdvancedFloatCell( + "Body collision UV relax", + "batch-adv-body-uv", + bodyUvRelax, + 0f, + 0.5f, + 0.005f, + "%.3f", + ModelDecimationAdvancedSettings.DefaultBodyCollisionAdaptiveUvThreshold, + "UV similarity threshold used in relaxed mode.", + itemWidth, + out var bodyUvRelaxValue)) + { + advanced.BodyCollisionAdaptiveUvThreshold = bodyUvRelaxValue; + _playerPerformanceConfig.Save(); + } + + if (DrawBatchAdvancedFloatCell( + "Body collision UV cosine", + "batch-adv-body-uv-cos", + bodyNoOpUvCos, + -1f, + 1f, + 0.01f, + "%.2f", + ModelDecimationAdvancedSettings.DefaultBodyCollisionNoOpUvSeamAngleCos, + "UV seam cosine used in relaxed mode.", + itemWidth, + out var bodyNoOpUvCosValue)) + { + advanced.BodyCollisionNoOpUvSeamAngleCos = bodyNoOpUvCosValue; + _playerPerformanceConfig.Save(); + } + + var bodyProtection = advanced.BodyCollisionProtectionFactor; + var bodyProxyMin = advanced.BodyProxyTargetRatioMin; + ImGui.TableNextRow(); + if (DrawBatchAdvancedFloatCell( + "Body collision protection", + "batch-adv-body-protect", + bodyProtection, + 0f, + 5f, + 0.01f, + "%.2f", + ModelDecimationAdvancedSettings.DefaultBodyCollisionProtectionFactor, + "Expansion factor for protected vertices near the body.", + itemWidth, + out var bodyProtectionValue)) + { + advanced.BodyCollisionProtectionFactor = bodyProtectionValue; + _playerPerformanceConfig.Save(); + } + + if (DrawBatchAdvancedFloatCell( + "Body proxy min ratio", + "batch-adv-body-proxy", + bodyProxyMin, + 0f, + 1f, + 0.01f, + "%.2f", + ModelDecimationAdvancedSettings.DefaultBodyProxyTargetRatioMin, + "Minimum target ratio when decimating the body proxy.", + itemWidth, + out var bodyProxyMinValue)) + { + advanced.BodyProxyTargetRatioMin = bodyProxyMinValue; + _playerPerformanceConfig.Save(); + } + + var bodyInflate = advanced.BodyCollisionProxyInflate; + var bodyPenetration = advanced.BodyCollisionPenetrationFactor; + ImGui.TableNextRow(); + if (DrawBatchAdvancedFloatCell( + "Body collision inflate", + "batch-adv-body-inflate", + bodyInflate, + 0f, + 0.01f, + 0.0001f, + "%.4f", + ModelDecimationAdvancedSettings.DefaultBodyCollisionProxyInflate, + "Inflate body collision distances by this offset.", + itemWidth, + out var bodyInflateValue)) + { + advanced.BodyCollisionProxyInflate = bodyInflateValue; + _playerPerformanceConfig.Save(); + } + + if (DrawBatchAdvancedFloatCell( + "Body penetration factor", + "batch-adv-body-penetration", + bodyPenetration, + 0f, + 1f, + 0.01f, + "%.2f", + ModelDecimationAdvancedSettings.DefaultBodyCollisionPenetrationFactor, + "Reject collapses that penetrate the body below this factor.", + itemWidth, + out var bodyPenetrationValue)) + { + advanced.BodyCollisionPenetrationFactor = bodyPenetrationValue; + _playerPerformanceConfig.Save(); + } + + var minBodyDistance = advanced.MinBodyCollisionDistance; + var minBodyCell = advanced.MinBodyCollisionCellSize; + ImGui.TableNextRow(); + if (DrawBatchAdvancedFloatCell( + "Min body collision distance", + "batch-adv-body-min-dist", + minBodyDistance, + 1e-6f, + 0.01f, + 0.00001f, + "%.6f", + ModelDecimationAdvancedSettings.DefaultMinBodyCollisionDistance, + "Lower bound for body collision distance.", + itemWidth, + out var minBodyDistanceValue)) + { + advanced.MinBodyCollisionDistance = minBodyDistanceValue; + _playerPerformanceConfig.Save(); + } + + if (DrawBatchAdvancedFloatCell( + "Min body collision cell size", + "batch-adv-body-min-cell", + minBodyCell, + 1e-6f, + 0.01f, + 0.00001f, + "%.6f", + ModelDecimationAdvancedSettings.DefaultMinBodyCollisionCellSize, + "Lower bound for the body collision grid size.", + itemWidth, + out var minBodyCellValue)) + { + advanced.MinBodyCollisionCellSize = minBodyCellValue; + _playerPerformanceConfig.Save(); + } + } + } + + private bool DrawBatchAdvancedIntCell( + string label, + string id, + int currentValue, + int minValue, + int maxValue, + int defaultValue, + string tooltip, + float itemWidth, + out int newValue) + { + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(label); + UiSharedService.AttachToolTip($"{tooltip}\nDefault: {defaultValue:N0}. Right-click to reset."); + + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(itemWidth); + newValue = currentValue; + var changed = ImGui.DragInt($"##{id}", ref newValue, 1f, minValue, maxValue); + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + newValue = defaultValue; + changed = true; + } + + return changed; + } + + private bool DrawBatchAdvancedFloatCell( + string label, + string id, + float currentValue, + float minValue, + float maxValue, + float speed, + string format, + float defaultValue, + string tooltip, + float itemWidth, + out float newValue) + { + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(label); + var defaultText = defaultValue.ToString("0.#######", CultureInfo.InvariantCulture); + UiSharedService.AttachToolTip($"{tooltip}\nDefault: {defaultText}. Right-click to reset."); + + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(itemWidth); + newValue = currentValue; + var changed = ImGui.DragFloat($"##{id}", ref newValue, speed, minValue, maxValue, format); + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + newValue = defaultValue; + changed = true; + } + + return changed; + } + + private bool DrawBatchAdvancedBoolCell( + string label, + string id, + bool currentValue, + bool defaultValue, + string tooltip, + Vector4 accent, + out bool newValue) + { + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(label); + UiSharedService.AttachToolTip($"{tooltip}\nDefault: {(defaultValue ? "On" : "Off")}. Right-click to reset."); + + ImGui.TableNextColumn(); + newValue = currentValue; + var changed = UiSharedService.CheckboxWithBorder($"##{id}", ref newValue, accent, 1.5f); + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + newValue = defaultValue; + changed = true; + } + + return changed; + } + + private static void DrawBatchAdvancedEmptyCell() + { + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + } + + private void DrawBatchAdvancedCategoryRow(string label, string tooltip, float scale) + { + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + ImGui.AlignTextToFramePadding(); + ImGui.TextColored(UIColors.Get("LightlessBlue"), label); + _uiSharedService.DrawHelpText(tooltip); + ImGui.TableSetColumnIndex(1); + ImGui.Dummy(Vector2.Zero); + ImGui.TableSetColumnIndex(2); + ImGui.Dummy(Vector2.Zero); + ImGui.TableSetColumnIndex(3); + ImGui.Dummy(Vector2.Zero); + } + + private void DrawModelBatchActions() + { + var scale = ImGuiHelpers.GlobalScale; + PruneModelSelections(); + var selectionCount = _selectedModelKeys.Count; + var decimationRunning = _modelDecimationTask != null && !_modelDecimationTask.IsCompleted; + + using (ImRaii.Disabled(decimationRunning || selectionCount == 0)) + { + var label = selectionCount > 0 ? $"Decimate {selectionCount} selected" : "Decimate selected"; + if (_uiSharedService.IconTextButton(FontAwesomeIcon.ProjectDiagram, label, 220f * scale)) + { + StartModelDecimationBatch(); + } + } + + ImGui.SameLine(); + using (ImRaii.Disabled(decimationRunning || _selectedModelKeys.Count == 0)) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Broom, "Clear marks", 160f * scale)) + { + _selectedModelKeys.Clear(); + } + } + + if (decimationRunning) + { + ImGui.SameLine(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.StopCircle, "Cancel", 120f * scale)) + { + _modelDecimationCts.Cancel(); + } + } + + ImGui.SameLine(); + var searchWidth = 220f * scale; + var searchStartX = ImGui.GetCursorPosX(); + var searchAvail = ImGui.GetContentRegionAvail().X; + var searchX = searchStartX + Math.Max(0f, searchAvail - searchWidth); + ImGui.SetCursorPosX(searchX); + ImGui.SetNextItemWidth(searchWidth); + var search = _modelSearch; + if (ImGui.InputTextWithHint("##model-search", "Search models...", ref search, 128)) + { + _modelSearch = search; + } + UiSharedService.AttachToolTip("Filter model rows by name, hash, or path."); + + if (decimationRunning) + { + var total = Math.Max(_modelDecimationTotalJobs, 1); + var completed = Math.Clamp(_modelDecimationCurrentProgress, 0, total); + var progress = (float)completed / total; + var label = string.IsNullOrEmpty(_modelDecimationCurrentHash) + ? $"{completed}/{total}" + : $"{completed}/{total} • {_modelDecimationCurrentHash}"; + ImGui.ProgressBar(progress, new Vector2(-1f, 0f), label); + } + } + + private void DrawModelDetailPane(IGrouping modelGroup) + { + var scale = ImGuiHelpers.GlobalScale; + CharacterAnalyzer.FileDataEntry? selected = null; + if (!string.IsNullOrEmpty(_selectedHash)) + { + selected = modelGroup.FirstOrDefault(entry => string.Equals(entry.Hash, _selectedHash, StringComparison.Ordinal)); + } + + UiSharedService.ColorText("Model Details", UIColors.Get("LightlessPurple")); + if (selected != null) + { + var sourcePath = selected.FilePaths.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(sourcePath)) + { + ImGui.SameLine(); + ImGui.TextUnformatted(Path.GetFileName(sourcePath)); + UiSharedService.AttachToolTip("Source file: " + sourcePath); + } + } + ImGui.Separator(); + + if (selected == null) + { + UiSharedService.ColorText("Select a model to view details.", ImGuiColors.DalamudGrey); + return; + } + + using (ImRaii.Child("modelDetailInfo", new Vector2(-1f, 0f), true, ImGuiWindowFlags.AlwaysVerticalScrollbar)) + { + var labelColor = ImGuiColors.DalamudGrey; + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + { + var metaFlags = ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.PadOuterX; + if (ImGui.BeginTable("modelMetaOverview", 2, metaFlags)) + { + MetaRow(FontAwesomeIcon.Cube, "Object", _selectedObjectTab.ToString()); + MetaRow(FontAwesomeIcon.Fingerprint, "Hash", selected.Hash, UIColors.Get("LightlessBlue")); + + var pendingColor = selected.IsComputed ? (Vector4?)null : UIColors.Get("LightlessYellow"); + var triangleLabel = selected.IsComputed + ? selected.Triangles.ToString("N0", CultureInfo.InvariantCulture) + : "Pending"; + MetaRow(FontAwesomeIcon.ProjectDiagram, "Triangles", triangleLabel, pendingColor); + + var originalLabel = selected.IsComputed + ? UiSharedService.ByteToString(selected.OriginalSize) + : "Pending"; + var compressedLabel = selected.IsComputed + ? UiSharedService.ByteToString(selected.CompressedSize) + : "Pending"; + MetaRow(FontAwesomeIcon.Database, "Original", originalLabel, pendingColor); + MetaRow(FontAwesomeIcon.CompressArrowsAlt, "Compressed", compressedLabel, pendingColor); + + ImGui.EndTable(); + } + } + + ImGuiHelpers.ScaledDummy(4); + + if (selected.IsComputed) + { + var savedBytes = selected.OriginalSize - selected.CompressedSize; + var savedMagnitude = Math.Abs(savedBytes); + var savedColor = savedBytes > 0 ? UIColors.Get("LightlessGreen") + : savedBytes < 0 ? UIColors.Get("DimRed") + : ImGuiColors.DalamudGrey; + var savedLabel = savedBytes > 0 ? "Saved" : savedBytes < 0 ? "Over" : "Delta"; + var savedPercent = selected.OriginalSize > 0 && savedMagnitude > 0 + ? $"{savedMagnitude * 100d / selected.OriginalSize:0.#}%" + : null; + + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + { + var statFlags = ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.PadOuterX; + if (ImGui.BeginTable("modelSizeSummary", 3, statFlags)) + { + ImGui.TableNextRow(); + StatCell(FontAwesomeIcon.Database, ImGuiColors.DalamudGrey, UiSharedService.ByteToString(selected.OriginalSize), "Original"); + StatCell(FontAwesomeIcon.CompressArrowsAlt, UIColors.Get("LightlessYellow2"), UiSharedService.ByteToString(selected.CompressedSize), "Compressed"); + StatCell(FontAwesomeIcon.ChartLine, savedColor, savedMagnitude > 0 ? UiSharedService.ByteToString(savedMagnitude) : "No change", savedLabel, savedPercent, savedColor); + ImGui.EndTable(); + } + } + } + else + { + UiSharedService.ColorTextWrapped("Size and triangle data are still being computed.", UIColors.Get("LightlessYellow")); + } + + ImGuiHelpers.ScaledDummy(6); + DrawPathList("File Paths", selected.FilePaths, "No file paths recorded."); + DrawPathList("Game Paths", selected.GamePaths, "No game paths recorded."); + + void MetaRow(FontAwesomeIcon icon, string label, string value, Vector4? valueColor = null) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + _uiSharedService.IconText(icon, labelColor); + ImGui.SameLine(0f, 4f * scale); + using (ImRaii.PushColor(ImGuiCol.Text, labelColor)) + { + ImGui.TextUnformatted(label); + } + + ImGui.TableNextColumn(); + if (valueColor.HasValue) + { + using (ImRaii.PushColor(ImGuiCol.Text, valueColor.Value)) + { + ImGui.TextUnformatted(value); + } + } + else + { + ImGui.TextUnformatted(value); + } + } + + void StatCell(FontAwesomeIcon icon, Vector4 iconColor, string mainText, string caption, string? extra = null, Vector4? extraColor = null) + { + ImGui.TableNextColumn(); + _uiSharedService.IconText(icon, iconColor); + ImGui.SameLine(0f, 4f * scale); + using (ImRaii.PushColor(ImGuiCol.Text, iconColor)) + { + ImGui.TextUnformatted(mainText); + } + using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudGrey)) + { + ImGui.TextUnformatted(caption); + } + if (!string.IsNullOrEmpty(extra)) + { + ImGui.SameLine(0f, 4f * scale); + using (ImRaii.PushColor(ImGuiCol.Text, extraColor ?? iconColor)) + { + ImGui.TextUnformatted(extra); + } + } + } + + void DrawPathList(string title, IReadOnlyList entries, string emptyMessage) + { + var count = entries.Count; + using var headerDefault = ImRaii.PushColor(ImGuiCol.Header, UiSharedService.Color(new Vector4(0.15f, 0.15f, 0.18f, 0.95f))); + using var headerHover = ImRaii.PushColor(ImGuiCol.HeaderHovered, UiSharedService.Color(new Vector4(0.2f, 0.2f, 0.25f, 1f))); + using var headerActive = ImRaii.PushColor(ImGuiCol.HeaderActive, UiSharedService.Color(new Vector4(0.25f, 0.25f, 0.3f, 1f))); + var label = $"{title} ({count})"; + if (!ImGui.CollapsingHeader(label, count == 0 ? ImGuiTreeNodeFlags.Leaf : ImGuiTreeNodeFlags.None)) + { + return; + } + + if (count == 0) + { + UiSharedService.ColorText(emptyMessage, ImGuiColors.DalamudGrey); + return; + } + + var tableFlags = ImGuiTableFlags.PadOuterX | ImGuiTableFlags.NoHostExtendX | ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.BordersOuter; + if (ImGui.BeginTable($"{title}Table", 2, tableFlags)) + { + ImGui.TableSetupColumn("#", ImGuiTableColumnFlags.WidthFixed, 28f * scale); + ImGui.TableSetupColumn("Path", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableHeadersRow(); + + for (int i = 0; i < entries.Count; i++) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{i + 1}."); + + ImGui.TableNextColumn(); + var wrapPos = ImGui.GetCursorPosX() + ImGui.GetColumnWidth(); + ImGui.PushTextWrapPos(wrapPos); + ImGui.TextUnformatted(entries[i]); + ImGui.PopTextWrapPos(); + } + + ImGui.EndTable(); + } + } + } + } + private void DrawSelectedFileDetails(IGrouping? fileGroup) { var hasGroup = fileGroup != null; @@ -2411,6 +3720,39 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase _showModal = true; } + private void StartModelDecimationBatch() + { + if (_modelDecimationTask != null && !_modelDecimationTask.IsCompleted) + { + return; + } + + if (_cachedAnalysis == null) + { + return; + } + + var selectedEntries = _cachedAnalysis.Values + .SelectMany(entries => entries.Values) + .Where(entry => string.Equals(entry.FileType, "mdl", StringComparison.OrdinalIgnoreCase)) + .Where(entry => _selectedModelKeys.Contains(entry.Hash)) + .ToList(); + + if (selectedEntries.Count == 0) + { + return; + } + + _modelDecimationCts = _modelDecimationCts.CancelRecreate(); + _modelDecimationTotalJobs = selectedEntries.Count; + _modelDecimationCurrentProgress = 0; + _modelDecimationCurrentHash = string.Empty; + _modelDecimationFailed = false; + + var settings = GetBatchDecimationSettings(); + _modelDecimationTask = RunModelDecimationAsync(selectedEntries, settings, _modelDecimationCts.Token); + } + private async Task RunTextureConversionAsync(List requests, CancellationToken token) { try @@ -2432,7 +3774,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase { try { - await _characterAnalyzer.UpdateFileEntriesAsync(affectedPaths, token).ConfigureAwait(false); + await _characterAnalyzer.UpdateFileEntriesAsync(affectedPaths, token, force: true).ConfigureAwait(false); _hasUpdate = true; } catch (OperationCanceledException) @@ -2463,6 +3805,81 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase } } + private async Task RunModelDecimationAsync( + List entries, + ModelDecimationSettings settings, + CancellationToken token) + { + var affectedPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + try + { + var completed = 0; + foreach (var entry in entries) + { + token.ThrowIfCancellationRequested(); + + var sourcePath = entry.FilePaths.FirstOrDefault(); + _modelDecimationCurrentHash = string.IsNullOrWhiteSpace(sourcePath) + ? entry.Hash + : Path.GetFileName(sourcePath); + _modelDecimationCurrentProgress = completed; + if (string.IsNullOrWhiteSpace(sourcePath)) + { + completed++; + continue; + } + + _modelDecimationService.ScheduleBatchDecimation(entry.Hash, sourcePath, settings); + await _modelDecimationService.WaitForPendingJobsAsync(new[] { entry.Hash }, token).ConfigureAwait(false); + + affectedPaths.Add(sourcePath); + completed++; + _modelDecimationCurrentProgress = completed; + } + + if (!token.IsCancellationRequested && affectedPaths.Count > 0) + { + await _characterAnalyzer.UpdateFileEntriesAsync(affectedPaths, token, force: true).ConfigureAwait(false); + _hasUpdate = true; + try + { + _ipcManager.Penumbra.RequestImmediateRedraw(0, RedrawType.Redraw); + } + catch (Exception redrawEx) + { + _logger.LogWarning(redrawEx, "Failed to request redraw after batch model decimation."); + } + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("Model decimation batch was cancelled."); + } + catch (Exception ex) + { + _modelDecimationFailed = true; + _logger.LogError(ex, "Model decimation batch failed."); + } + finally + { + _modelDecimationCurrentHash = string.Empty; + _selectedModelKeys.Clear(); + } + } + + private ModelDecimationSettings GetBatchDecimationSettings() + { + var config = _playerPerformanceConfig.Current; + var ratio = Math.Clamp(config.BatchModelDecimationTargetRatio, 0.01, 0.99); + var advanced = config.ModelDecimationAdvanced; + return new ModelDecimationSettings( + Math.Max(0, config.BatchModelDecimationTriangleThreshold), + ratio, + config.BatchModelDecimationNormalizeTangents, + config.BatchModelDecimationAvoidBodyIntersection, + advanced); + } + private bool DrawVerticalResizeHandle( string id, float topY, @@ -2473,12 +3890,14 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase out bool isDragging, bool invert = false, bool showToggle = false, - bool isCollapsed = false) + bool isCollapsed = false, + float? splitterWidthOverride = null) { var scale = ImGuiHelpers.GlobalScale; - var splitterWidth = (showToggle + var baseWidth = splitterWidthOverride ?? (showToggle ? (isCollapsed ? TextureDetailSplitterCollapsedWidth : TextureDetailSplitterWidth) - : TextureFilterSplitterWidth) * scale; + : TextureFilterSplitterWidth); + var splitterWidth = baseWidth * scale; ImGui.SameLine(); var cursor = ImGui.GetCursorPos(); var contentMin = ImGui.GetWindowContentRegionMin(); @@ -2577,6 +3996,55 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase return toggleClicked; } + private void DrawHorizontalResizeHandle( + string id, + ref float topHeight, + float minHeight, + float maxHeight, + out bool isDragging, + bool invert = false, + float? splitterHeightOverride = null) + { + var scale = ImGuiHelpers.GlobalScale; + var baseHeight = splitterHeightOverride ?? ModelBatchSplitterHeight; + var splitterHeight = baseHeight * scale; + var width = ImGui.GetContentRegionAvail().X; + if (width <= 0f || splitterHeight <= 0f) + { + isDragging = false; + return; + } + + ImGui.InvisibleButton(id, new Vector2(width, splitterHeight)); + var drawList = ImGui.GetWindowDrawList(); + var rectMin = ImGui.GetItemRectMin(); + var rectMax = ImGui.GetItemRectMax(); + var windowPos = ImGui.GetWindowPos(); + var contentMin = ImGui.GetWindowContentRegionMin(); + var contentMax = ImGui.GetWindowContentRegionMax(); + var clipMin = windowPos + contentMin; + var clipMax = windowPos + contentMax; + drawList.PushClipRect(clipMin, clipMax, true); + + var hovered = ImGui.IsItemHovered(); + isDragging = ImGui.IsItemActive(); + var baseColor = UIColors.Get("ButtonDefault"); + var hoverColor = UIColors.Get("LightlessPurple"); + var activeColor = UIColors.Get("LightlessPurpleActive"); + var handleColor = isDragging ? activeColor : hovered ? hoverColor : baseColor; + var rounding = ImGui.GetStyle().FrameRounding; + drawList.AddRectFilled(rectMin, rectMax, UiSharedService.Color(handleColor), rounding); + drawList.AddRect(rectMin, rectMax, UiSharedService.Color(new Vector4(1f, 1f, 1f, 0.12f)), rounding); + drawList.PopClipRect(); + + if (isDragging) + { + var delta = ImGui.GetIO().MouseDelta.Y / scale; + topHeight += invert ? -delta : delta; + topHeight = Math.Clamp(topHeight, minHeight, maxHeight); + } + } + private (IDalamudTextureWrap? Texture, bool IsLoading, string? Error) GetTexturePreview(TextureRow row) { var key = row.Key; @@ -3370,24 +4838,33 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private void DrawTable(IGrouping fileGroup) { - var tableColumns = string.Equals(fileGroup.Key, "mdl", StringComparison.Ordinal) ? 6 : 5; + var isModel = string.Equals(fileGroup.Key, "mdl", StringComparison.OrdinalIgnoreCase); + var tableColumns = 5; var scale = ImGuiHelpers.GlobalScale; + var selectionAccent = UIColors.Get("LightlessOrange"); using var table = ImRaii.Table("Analysis", tableColumns, - ImGuiTableFlags.Sortable | ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY | ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.BordersOuter | ImGuiTableFlags.BordersInnerV, + ImGuiTableFlags.Sortable | ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY | ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.BordersOuter | ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.PadOuterX | ImGuiTableFlags.NoHostExtendX, new Vector2(-1f, 0f)); if (!table.Success) { return; } - ImGui.TableSetupColumn("Hash"); - ImGui.TableSetupColumn("Filepaths"); - ImGui.TableSetupColumn("Gamepaths"); - ImGui.TableSetupColumn("Original Size"); - ImGui.TableSetupColumn("Compressed Size"); - if (string.Equals(fileGroup.Key, "mdl", StringComparison.Ordinal)) + if (isModel) { - ImGui.TableSetupColumn("Triangles"); + ImGui.TableSetupColumn("##select", ImGuiTableColumnFlags.WidthFixed | ImGuiTableColumnFlags.NoSort, 32f * scale); + ImGui.TableSetupColumn("Model", ImGuiTableColumnFlags.WidthFixed, 380f * scale); + ImGui.TableSetupColumn("Triangles", ImGuiTableColumnFlags.WidthFixed, 120f * scale); + ImGui.TableSetupColumn("Original", ImGuiTableColumnFlags.WidthFixed, 140f * scale); + ImGui.TableSetupColumn("Compressed", ImGuiTableColumnFlags.WidthFixed, 140f * scale); + } + else + { + ImGui.TableSetupColumn("Hash", ImGuiTableColumnFlags.WidthFixed, 320f * scale); + ImGui.TableSetupColumn("Original", ImGuiTableColumnFlags.WidthFixed, 140f * scale); + ImGui.TableSetupColumn("Compressed", ImGuiTableColumnFlags.WidthFixed, 140f * scale); + ImGui.TableSetupColumn("File paths", ImGuiTableColumnFlags.WidthFixed, 90f * scale); + ImGui.TableSetupColumn("Game paths", ImGuiTableColumnFlags.WidthFixed, 90f * scale); } ImGui.TableSetupScrollFreeze(0, 1); @@ -3399,73 +4876,192 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase var spec = sortSpecs.Specs[0]; bool ascending = spec.SortDirection == ImGuiSortDirection.Ascending; - switch (spec.ColumnIndex) + var columnIndex = (int)spec.ColumnIndex; + if (isModel) { - case 0: - SortCachedAnalysis(_selectedObjectTab, pair => pair.Key, ascending, StringComparer.Ordinal); - break; - case 1: - SortCachedAnalysis(_selectedObjectTab, pair => pair.Value.FilePaths.Count, ascending); - break; - case 2: - SortCachedAnalysis(_selectedObjectTab, pair => pair.Value.GamePaths.Count, ascending); - break; - case 3: - SortCachedAnalysis(_selectedObjectTab, pair => pair.Value.OriginalSize, ascending); - break; - case 4: - SortCachedAnalysis(_selectedObjectTab, pair => pair.Value.CompressedSize, ascending); - break; - case 5 when string.Equals(fileGroup.Key, "mdl", StringComparison.Ordinal): + if (columnIndex == 0) + { + // checkbox column + } + else if (columnIndex == 1) + { + SortCachedAnalysis(_selectedObjectTab, pair => GetModelDisplayName(pair.Value), ascending, StringComparer.OrdinalIgnoreCase); + } + else if (columnIndex == 2) + { SortCachedAnalysis(_selectedObjectTab, pair => pair.Value.Triangles, ascending); - break; + } + else if (columnIndex == 3) + { + SortCachedAnalysis(_selectedObjectTab, pair => pair.Value.OriginalSize, ascending); + } + else if (columnIndex == 4) + { + SortCachedAnalysis(_selectedObjectTab, pair => pair.Value.CompressedSize, ascending); + } + } + else + { + if (columnIndex == 0) + { + SortCachedAnalysis(_selectedObjectTab, pair => pair.Key, ascending, StringComparer.Ordinal); + } + else if (columnIndex == 1) + { + SortCachedAnalysis(_selectedObjectTab, pair => pair.Value.OriginalSize, ascending); + } + else if (columnIndex == 2) + { + SortCachedAnalysis(_selectedObjectTab, pair => pair.Value.CompressedSize, ascending); + } + else if (columnIndex == 3) + { + SortCachedAnalysis(_selectedObjectTab, pair => pair.Value.FilePaths.Count, ascending); + } + else if (columnIndex == 4) + { + SortCachedAnalysis(_selectedObjectTab, pair => pair.Value.GamePaths.Count, ascending); + } } sortSpecs.SpecsDirty = false; } - foreach (var item in fileGroup) + IEnumerable entries = fileGroup; + if (isModel && !string.IsNullOrWhiteSpace(_modelSearch)) { - using var textColor = ImRaii.PushColor(ImGuiCol.Text, new Vector4(0, 0, 0, 1), string.Equals(item.Hash, _selectedHash, StringComparison.Ordinal)); - using var missingColor = ImRaii.PushColor(ImGuiCol.Text, new Vector4(1, 1, 1, 1), !item.IsComputed); - ImGui.TableNextColumn(); - if (!item.IsComputed) - { - var warning = UiSharedService.Color(UIColors.Get("DimRed")); - ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg1, warning); - ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg0, warning); - } - if (string.Equals(_selectedHash, item.Hash, StringComparison.Ordinal)) - { - var highlight = UiSharedService.Color(UIColors.Get("LightlessYellow")); - ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg1, highlight); - ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg0, highlight); - } - ImGui.TextUnformatted(item.Hash); - if (ImGui.IsItemClicked()) - { - _selectedHash = string.Equals(_selectedHash, item.Hash, StringComparison.Ordinal) - ? string.Empty - : item.Hash; - } + var term = _modelSearch.Trim(); + entries = fileGroup.Where(entry => + entry.Hash.Contains(term, StringComparison.OrdinalIgnoreCase) + || GetModelDisplayName(entry).Contains(term, StringComparison.OrdinalIgnoreCase) + || entry.FilePaths.Exists(path => path.Contains(term, StringComparison.OrdinalIgnoreCase)) + || entry.GamePaths.Exists(path => path.Contains(term, StringComparison.OrdinalIgnoreCase))); + } - ImGui.TableNextColumn(); - ImGui.TextUnformatted(item.FilePaths.Count.ToString()); - - ImGui.TableNextColumn(); - ImGui.TextUnformatted(item.GamePaths.Count.ToString()); - - ImGui.TableNextColumn(); - ImGui.TextUnformatted(UiSharedService.ByteToString(item.OriginalSize)); - - ImGui.TableNextColumn(); - ImGui.TextUnformatted(UiSharedService.ByteToString(item.CompressedSize)); - - if (string.Equals(fileGroup.Key, "mdl", StringComparison.Ordinal)) + foreach (var item in entries) + { + var isSelected = string.Equals(item.Hash, _selectedHash, StringComparison.Ordinal); + var defaultTextColor = ImGui.GetColorU32(ImGuiCol.Text); + if (isModel) { ImGui.TableNextColumn(); - ImGui.TextUnformatted(item.Triangles.ToString()); + var marked = _selectedModelKeys.Contains(item.Hash); + if (UiSharedService.CheckboxWithBorder($"##model-select-{item.Hash}", ref marked, selectionAccent, 1.5f)) + { + if (marked) + { + _selectedModelKeys.Add(item.Hash); + } + else + { + _selectedModelKeys.Remove(item.Hash); + } + } + + using (ImRaii.PushColor(ImGuiCol.Text, defaultTextColor)) + { + UiSharedService.AttachToolTip("Mark model for batch decimation."); + } + ImGui.TableNextColumn(); } + else + { + ImGui.TableNextColumn(); + } + + using var textColor = ImRaii.PushColor(ImGuiCol.Text, new Vector4(0, 0, 0, 1), isSelected); + using var missingColor = ImRaii.PushColor(ImGuiCol.Text, new Vector4(1, 1, 1, 1), !item.IsComputed); + if (isModel) + { + if (!item.IsComputed) + { + var warning = UiSharedService.Color(UIColors.Get("DimRed")); + ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg1, warning); + ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg0, warning); + } + if (isSelected) + { + var highlight = UiSharedService.Color(UIColors.Get("LightlessYellow")); + ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg1, highlight); + ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg0, highlight); + } + + var displayName = GetModelDisplayName(item); + ImGui.TextUnformatted(displayName); + using (ImRaii.PushColor(ImGuiCol.Text, defaultTextColor)) + { + UiSharedService.AttachToolTip($"Hash: {item.Hash}"); + } + if (ImGui.IsItemClicked()) + { + _selectedHash = isSelected ? string.Empty : item.Hash; + } + } + else + { + if (!item.IsComputed) + { + var warning = UiSharedService.Color(UIColors.Get("DimRed")); + ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg1, warning); + ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg0, warning); + } + if (isSelected) + { + var highlight = UiSharedService.Color(UIColors.Get("LightlessYellow")); + ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg1, highlight); + ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg0, highlight); + } + + ImGui.TextUnformatted(item.Hash); + if (ImGui.IsItemClicked()) + { + _selectedHash = isSelected ? string.Empty : item.Hash; + } + } + + if (isModel) + { + ImGui.TableNextColumn(); + ImGui.TextUnformatted(item.IsComputed + ? item.Triangles.ToString("N0", CultureInfo.InvariantCulture) + : "Pending"); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(item.IsComputed ? UiSharedService.ByteToString(item.OriginalSize) : "Pending"); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(item.IsComputed ? UiSharedService.ByteToString(item.CompressedSize) : "Pending"); + } + else + { + ImGui.TableNextColumn(); + ImGui.TextUnformatted(item.IsComputed ? UiSharedService.ByteToString(item.OriginalSize) : "Pending"); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(item.IsComputed ? UiSharedService.ByteToString(item.CompressedSize) : "Pending"); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(item.FilePaths.Count.ToString(CultureInfo.InvariantCulture)); + if (item.FilePaths.Count > 0) + { + UiSharedService.AttachToolTip(string.Join(Environment.NewLine, item.FilePaths)); + } + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(item.GamePaths.Count.ToString(CultureInfo.InvariantCulture)); + if (item.GamePaths.Count > 0) + { + UiSharedService.AttachToolTip(string.Join(Environment.NewLine, item.GamePaths)); + } + } + } + + static string GetModelDisplayName(CharacterAnalyzer.FileDataEntry entry) + { + var sourcePath = entry.FilePaths.FirstOrDefault(); + return string.IsNullOrWhiteSpace(sourcePath) + ? entry.Hash + : Path.GetFileName(sourcePath); } } } -- 2.49.1 From e8c7539770e74fe5519b60ea2b0b285dcc9d1588 Mon Sep 17 00:00:00 2001 From: azyges Date: Mon, 19 Jan 2026 09:57:50 +0900 Subject: [PATCH 74/87] fix log level --- .../Services/ModelDecimation/MdlDecimator.cs | 29 +++++++++---------- .../ModelDecimation/ModelDecimationService.cs | 12 ++++---- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/LightlessSync/Services/ModelDecimation/MdlDecimator.cs b/LightlessSync/Services/ModelDecimation/MdlDecimator.cs index cbecf68..55b511c 100644 --- a/LightlessSync/Services/ModelDecimation/MdlDecimator.cs +++ b/LightlessSync/Services/ModelDecimation/MdlDecimator.cs @@ -59,25 +59,25 @@ internal static class MdlDecimator var tuning = settings.Advanced; if (!TryReadModelBytes(sourcePath, logger, out var data)) { - logger.LogInformation("Skipping model decimation; source file locked or unreadable: {Path}", sourcePath); + logger.LogDebug("Skipping model decimation; source file locked or unreadable: {Path}", sourcePath); return false; } var mdl = new MdlFile(data); if (!mdl.Valid) { - logger.LogInformation("Skipping model decimation; invalid mdl: {Path}", sourcePath); + logger.LogDebug("Skipping model decimation; invalid mdl: {Path}", sourcePath); return false; } if (mdl.LodCount != 1) { - logger.LogInformation("Skipping model decimation; unsupported LOD count for {Path}", sourcePath); + logger.LogDebug("Skipping model decimation; unsupported LOD count for {Path}", sourcePath); return false; } if (HasShapeData(mdl)) { - logger.LogInformation("Skipping model decimation; shape/morph data present for {Path}", sourcePath); + logger.LogDebug("Skipping model decimation; shape/morph data present for {Path}", sourcePath); return false; } @@ -86,13 +86,13 @@ internal static class MdlDecimator var meshes = mdl.Meshes.ToArray(); if (meshes.Length == 0) { - logger.LogInformation("Skipping model decimation; no meshes for {Path}", sourcePath); + logger.LogDebug("Skipping model decimation; no meshes for {Path}", sourcePath); return false; } if (lod.MeshCount == 0) { - logger.LogInformation("Skipping model decimation; no meshes for {Path}", sourcePath); + logger.LogDebug("Skipping model decimation; no meshes for {Path}", sourcePath); return false; } @@ -100,7 +100,7 @@ internal static class MdlDecimator var lodMeshEnd = lodMeshStart + lod.MeshCount; if (lodMeshStart < 0 || lodMeshEnd > meshes.Length) { - logger.LogInformation("Skipping model decimation; invalid LOD mesh range for {Path}", sourcePath); + logger.LogDebug("Skipping model decimation; invalid LOD mesh range for {Path}", sourcePath); return false; } @@ -214,7 +214,7 @@ internal static class MdlDecimator if (!anyDecimated) { - logger.LogInformation("Skipping model decimation; no eligible meshes for {Path}", sourcePath); + logger.LogDebug("Skipping model decimation; no eligible meshes for {Path}", sourcePath); return false; } @@ -426,7 +426,7 @@ internal static class MdlDecimator if (decimatedTriangles <= 0 || decimatedTriangles >= triangleCount) { - logger.LogInformation( + logger.LogDebug( "Mesh {MeshIndex} decimation produced no reduction (before {Before}, after {After}, components {Components}, eligible {Eligible}, min {Min}, max {Max}, avg {Avg:0.##}, eval {Evaluated}, collapsed {Collapsed}, reject bone {RejectBone}, body {RejectBody}, topo {RejectTopo}, invert {RejectInvert} (deg {RejectDeg}, area {RejectArea}, flip {RejectFlip})", meshIndex, triangleCount, @@ -1220,17 +1220,16 @@ internal static class MdlDecimator bodyMeshOverrides = []; var meshCount = Math.Max(0, lodMeshEnd - lodMeshStart); - logger.LogInformation("Body collision: scanning {MeshCount} meshes, {MaterialCount} materials", meshCount, mdl.Materials.Length); + logger.LogDebug("Body collision: scanning {MeshCount} meshes, {MaterialCount} materials", meshCount, mdl.Materials.Length); if (mdl.Materials.Length == 0) { - logger.LogInformation("Body collision: no materials found, skipping body collision."); + logger.LogDebug("Body collision: no materials found, skipping body collision."); return false; } var materialList = string.Join(", ", mdl.Materials); - logger.LogInformation("Body collision: model materials = {Materials}", materialList); - logger.LogDebug("Body collision: model materials (debug) = {Materials}", materialList); + logger.LogDebug("Body collision: model materials = {Materials}", materialList); var proxyTargetRatio = Math.Clamp(Math.Max(settings.TargetRatio, tuning.BodyProxyTargetRatioMin), 0d, 1d); var bodyPositions = new List(); @@ -1244,7 +1243,7 @@ internal static class MdlDecimator ? mdl.Materials[mesh.MaterialIndex] : "(missing material)"; var isBody = IsBodyMaterial(material); - logger.LogInformation("Body collision: mesh {MeshIndex} material {Material} body {IsBody}", meshIndex, material, isBody); + logger.LogDebug("Body collision: mesh {MeshIndex} material {Material} body {IsBody}", meshIndex, material, isBody); if (!isBody) { @@ -1319,7 +1318,7 @@ internal static class MdlDecimator if (!foundBody) { - logger.LogInformation("Body collision: no body meshes matched filter."); + logger.LogDebug("Body collision: no body meshes matched filter."); return false; } diff --git a/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs b/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs index 0195a0c..00406f6 100644 --- a/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs +++ b/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs @@ -51,7 +51,7 @@ public sealed class ModelDecimationService return; } - _logger.LogInformation("Queued model decimation for {Hash}", hash); + _logger.LogDebug("Queued model decimation for {Hash}", hash); _decimationDeduplicator.GetOrStart(hash, async () => { @@ -174,7 +174,7 @@ public sealed class ModelDecimationService { if (!TryGetDecimationSettings(out var settings)) { - _logger.LogInformation("Model decimation disabled or invalid settings for {Hash}", hash); + _logger.LogDebug("Model decimation disabled or invalid settings for {Hash}", hash); return Task.CompletedTask; } @@ -198,11 +198,11 @@ public sealed class ModelDecimationService if (!TryNormalizeSettings(settings, out var normalized)) { - _logger.LogInformation("Model decimation skipped for {Hash}; invalid settings.", hash); + _logger.LogDebug("Model decimation skipped for {Hash}; invalid settings.", hash); return Task.CompletedTask; } - _logger.LogInformation( + _logger.LogDebug( "Starting model decimation for {Hash} (threshold {Threshold}, ratio {Ratio:0.##}, normalize tangents {NormalizeTangents}, avoid body intersection {AvoidBodyIntersection})", hash, normalized.TriangleThreshold, @@ -229,7 +229,7 @@ public sealed class ModelDecimationService if (!MdlDecimator.TryDecimate(sourcePath, destination, normalized, _logger)) { _failedHashes[hash] = 1; - _logger.LogInformation("Model decimation skipped for {Hash}", hash); + _logger.LogDebug("Model decimation skipped for {Hash}", hash); return Task.CompletedTask; } @@ -237,7 +237,7 @@ public sealed class ModelDecimationService { RegisterDecimatedModel(hash, sourcePath, destination); } - _logger.LogInformation("Decimated model {Hash} -> {Path}", hash, destination); + _logger.LogDebug("Decimated model {Hash} -> {Path}", hash, destination); return Task.CompletedTask; } -- 2.49.1 From 4fa9876c1c47b3231167a4849b61f89239be8469 Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 19 Jan 2026 03:35:36 +0100 Subject: [PATCH 75/87] Fix build --- .../LightlessConfiguration/Configurations/LightlessConfig.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index e3b2862..4a2b2c9 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -161,8 +161,8 @@ public class LightlessConfig : ILightlessConfiguration public string LastSeenVersion { get; set; } = string.Empty; public bool EnableParticleEffects { get; set; } = true; public HashSet OrphanableTempCollections { get; set; } = []; + public List OrphanableTempCollectionEntries { get; set; } = []; public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Unsafe; public bool AnimationAllowOneBasedShift { get; set; } = false; - public bool AnimationAllowNeighborIndexTolerance { get; set; } = false; } -- 2.49.1 From d6437998ac2bbf424ef438a0625427b8794fffd2 Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 19 Jan 2026 03:51:08 +0100 Subject: [PATCH 76/87] Added imgui flag for notifcation --- LightlessSync/UI/LightlessNotificationUI.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/LightlessSync/UI/LightlessNotificationUI.cs b/LightlessSync/UI/LightlessNotificationUI.cs index 1d0a477..82e1cd4 100644 --- a/LightlessSync/UI/LightlessNotificationUI.cs +++ b/LightlessSync/UI/LightlessNotificationUI.cs @@ -47,7 +47,9 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoScrollbar | - ImGuiWindowFlags.AlwaysAutoResize; + ImGuiWindowFlags.NoFocusOnAppearing | + ImGuiWindowFlags.AlwaysAutoResize; + PositionCondition = ImGuiCond.Always; SizeCondition = ImGuiCond.FirstUseEver; -- 2.49.1 From e75a37147596a5feb7009a2236df13ae94096bfb Mon Sep 17 00:00:00 2001 From: azyges Date: Mon, 19 Jan 2026 14:14:14 +0900 Subject: [PATCH 77/87] improve filtering --- .../Services/ModelDecimation/MdlDecimator.cs | 15 +-- .../ModelDecimation/ModelDecimationFilters.cs | 126 ++++++++++++++++++ .../ModelDecimation/ModelDecimationService.cs | 34 ++--- 3 files changed, 142 insertions(+), 33 deletions(-) create mode 100644 LightlessSync/Services/ModelDecimation/ModelDecimationFilters.cs diff --git a/LightlessSync/Services/ModelDecimation/MdlDecimator.cs b/LightlessSync/Services/ModelDecimation/MdlDecimator.cs index 55b511c..4fcdbc0 100644 --- a/LightlessSync/Services/ModelDecimation/MdlDecimator.cs +++ b/LightlessSync/Services/ModelDecimation/MdlDecimator.cs @@ -1343,22 +1343,11 @@ internal static class MdlDecimator return false; } - return IsBodyMaterial(mdl.Materials[mesh.MaterialIndex]); + return ModelDecimationFilters.IsBodyMaterial(mdl.Materials[mesh.MaterialIndex]); } private static bool IsBodyMaterial(string materialPath) - { - if (string.IsNullOrWhiteSpace(materialPath)) - { - return false; - } - - var normalized = materialPath.Replace('\\', '/').ToLowerInvariant(); - var nameStart = normalized.LastIndexOf('/'); - var fileName = nameStart >= 0 ? normalized[(nameStart + 1)..] : normalized; - return fileName.Contains("_bibo", StringComparison.Ordinal) - || fileName.EndsWith("_a.mtrl", StringComparison.Ordinal); - } + => ModelDecimationFilters.IsBodyMaterial(materialPath); private sealed class BodyCollisionData { diff --git a/LightlessSync/Services/ModelDecimation/ModelDecimationFilters.cs b/LightlessSync/Services/ModelDecimation/ModelDecimationFilters.cs new file mode 100644 index 0000000..622410c --- /dev/null +++ b/LightlessSync/Services/ModelDecimation/ModelDecimationFilters.cs @@ -0,0 +1,126 @@ +namespace LightlessSync.Services.ModelDecimation; + +internal static class ModelDecimationFilters +{ + // MODELS ONLY HERE, NOT MATERIALS + internal static readonly string[] HairPaths = + [ + "/hair/", + "hir.mdl", + ]; + + internal static readonly string[] ClothingPaths = + [ + "chara/equipment/", + "/equipment/", + + "met.mdl", + "top.mdl", + "glv.mdl", + "dwn.mdl", + "sho.mdl", + ]; + + internal static readonly string[] AccessoryPaths = + [ + "/accessory/", + "chara/accessory/", + + "ear.mdl", + "nek.mdl", + "wrs.mdl", + "ril.mdl", + "rir.mdl", + ]; + + internal static readonly string[] BodyPaths = + [ + "/body/", + "chara/equipment/e0000/model/", + "chara/equipment/e9903/model/", + "chara/equipment/e9903/model/", + ]; + + internal static readonly string[] FaceHeadPaths = + [ + "/face/", + "/obj/face/", + "/head/", + "fac.mdl", + ]; + + internal static readonly string[] TailOrEarPaths = + [ + "/tail/", + "/obj/tail/", + "/zear/", + "/obj/zear/", + + "til.mdl", + "zer.mdl", + ]; + + // BODY MATERIALS ONLY, NOT MESHES + internal static readonly string[] BodyMaterials = + [ + "_bibo", + "_a.mtrl", + "_b.mtrl", + ]; + + internal static string NormalizePath(string path) + => path.Replace('\\', '/').ToLowerInvariant(); + + internal static bool IsHairPath(string normalizedPath) + => ContainsAny(normalizedPath, HairPaths); + + internal static bool IsClothingPath(string normalizedPath) + => ContainsAny(normalizedPath, ClothingPaths); + + internal static bool IsAccessoryPath(string normalizedPath) + => ContainsAny(normalizedPath, AccessoryPaths); + + + internal static bool IsBodyPath(string normalizedPath) + => ContainsAny(normalizedPath, BodyPaths); + + internal static bool IsFaceHeadPath(string normalizedPath) + => ContainsAny(normalizedPath, FaceHeadPaths); + + internal static bool IsTailOrEarPath(string normalizedPath) + => ContainsAny(normalizedPath, TailOrEarPaths); + + internal static bool ContainsAny(string normalizedPath, IReadOnlyList markers) + { + for (var i = 0; i < markers.Count; i++) + { + if (normalizedPath.Contains(markers[i], StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + internal static bool IsBodyMaterial(string materialPath) + { + if (string.IsNullOrWhiteSpace(materialPath)) + { + return false; + } + + var normalized = NormalizePath(materialPath); + var nameStart = normalized.LastIndexOf('/'); + var fileName = nameStart >= 0 ? normalized[(nameStart + 1)..] : normalized; + foreach (var marker in BodyMaterials) + { + if (fileName.Contains(marker, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } +} diff --git a/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs b/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs index 00406f6..3caa070 100644 --- a/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs +++ b/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs @@ -348,46 +348,40 @@ public sealed class ModelDecimationService return true; } - var normalized = NormalizeGamePath(gamePath); - if (normalized.Contains("/hair/", StringComparison.Ordinal)) + var normalized = ModelDecimationFilters.NormalizePath(gamePath); + if (ModelDecimationFilters.IsHairPath(normalized)) { return false; } - if (normalized.Contains("/chara/equipment/", StringComparison.Ordinal)) + if (ModelDecimationFilters.IsClothingPath(normalized)) { return _performanceConfigService.Current.ModelDecimationAllowClothing; } - if (normalized.Contains("/chara/accessory/", StringComparison.Ordinal)) + if (ModelDecimationFilters.IsAccessoryPath(normalized)) { return _performanceConfigService.Current.ModelDecimationAllowAccessories; } - if (normalized.Contains("/chara/human/", StringComparison.Ordinal)) + if (ModelDecimationFilters.IsBodyPath(normalized)) { - if (normalized.Contains("/body/", StringComparison.Ordinal)) - { - return _performanceConfigService.Current.ModelDecimationAllowBody; - } + return _performanceConfigService.Current.ModelDecimationAllowBody; + } - if (normalized.Contains("/face/", StringComparison.Ordinal) || normalized.Contains("/head/", StringComparison.Ordinal)) - { - return _performanceConfigService.Current.ModelDecimationAllowFaceHead; - } + if (ModelDecimationFilters.IsFaceHeadPath(normalized)) + { + return _performanceConfigService.Current.ModelDecimationAllowFaceHead; + } - if (normalized.Contains("/tail/", StringComparison.Ordinal)) - { - return _performanceConfigService.Current.ModelDecimationAllowTail; - } + if (ModelDecimationFilters.IsTailOrEarPath(normalized)) + { + return _performanceConfigService.Current.ModelDecimationAllowTail; } return true; } - private static string NormalizeGamePath(string path) - => path.Replace('\\', '/').ToLowerInvariant(); - private bool TryGetDecimationSettings(out ModelDecimationSettings settings) { settings = new ModelDecimationSettings( -- 2.49.1 From d039d2fd90e9a2f692b17bddb6dbba9f1fbbca0b Mon Sep 17 00:00:00 2001 From: azyges Date: Mon, 19 Jan 2026 14:19:08 +0900 Subject: [PATCH 78/87] add emperors oops --- LightlessSync/Services/ModelDecimation/ModelDecimationFilters.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/LightlessSync/Services/ModelDecimation/ModelDecimationFilters.cs b/LightlessSync/Services/ModelDecimation/ModelDecimationFilters.cs index 622410c..7cd4d91 100644 --- a/LightlessSync/Services/ModelDecimation/ModelDecimationFilters.cs +++ b/LightlessSync/Services/ModelDecimation/ModelDecimationFilters.cs @@ -39,6 +39,7 @@ internal static class ModelDecimationFilters "chara/equipment/e0000/model/", "chara/equipment/e9903/model/", "chara/equipment/e9903/model/", + "chara/equipment/e0279/model/", ]; internal static readonly string[] FaceHeadPaths = -- 2.49.1 From 828be6eb5b9c8355869d3fe85f91831d0e551142 Mon Sep 17 00:00:00 2001 From: azyges Date: Mon, 19 Jan 2026 21:20:54 +0900 Subject: [PATCH 79/87] fix body materials --- .../ModelDecimation/ModelDecimationFilters.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/LightlessSync/Services/ModelDecimation/ModelDecimationFilters.cs b/LightlessSync/Services/ModelDecimation/ModelDecimationFilters.cs index 7cd4d91..f2d7f8a 100644 --- a/LightlessSync/Services/ModelDecimation/ModelDecimationFilters.cs +++ b/LightlessSync/Services/ModelDecimation/ModelDecimationFilters.cs @@ -64,9 +64,14 @@ internal static class ModelDecimationFilters // BODY MATERIALS ONLY, NOT MESHES internal static readonly string[] BodyMaterials = [ - "_bibo", - "_a.mtrl", - "_b.mtrl", + "b0001_bibo.mtrl", + "b0101_bibo.mtrl", + + "b0001_a.mtrl", + "b0001_b.mtrl", + + "b0101_a.mtrl", + "b0101_b.mtrl", ]; internal static string NormalizePath(string path) -- 2.49.1 From f27db300ec22799873f41716a761530fb227e802 Mon Sep 17 00:00:00 2001 From: azyges Date: Mon, 19 Jan 2026 23:04:54 +0900 Subject: [PATCH 80/87] fix temp collection config and migrate it --- .../ConfigurationMigrator.cs | 281 +++++++++++++++++- .../ConfigurationSaveService.cs | 60 ++-- .../Configurations/LightlessConfig.cs | 2 - .../Configurations/TempCollectionConfig.cs | 10 + .../TempCollectionConfigService.cs | 12 + LightlessSync/Plugin.cs | 2 + .../Services/PenumbraTempCollectionJanitor.cs | 102 ++++--- 7 files changed, 400 insertions(+), 69 deletions(-) create mode 100644 LightlessSync/LightlessConfiguration/Configurations/TempCollectionConfig.cs create mode 100644 LightlessSync/LightlessConfiguration/TempCollectionConfigService.cs diff --git a/LightlessSync/LightlessConfiguration/ConfigurationMigrator.cs b/LightlessSync/LightlessConfiguration/ConfigurationMigrator.cs index ce2ac17..55fa984 100644 --- a/LightlessSync/LightlessConfiguration/ConfigurationMigrator.cs +++ b/LightlessSync/LightlessConfiguration/ConfigurationMigrator.cs @@ -1,11 +1,17 @@ -using LightlessSync.WebAPI; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using LightlessSync.LightlessConfiguration.Configurations; +using LightlessSync.LightlessConfiguration.Models; +using LightlessSync.WebAPI; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace LightlessSync.LightlessConfiguration; public class ConfigurationMigrator(ILogger logger, TransientConfigService transientConfigService, - ServerConfigService serverConfigService) : IHostedService + ServerConfigService serverConfigService, TempCollectionConfigService tempCollectionConfigService, + LightlessConfigService lightlessConfigService) : IHostedService { private readonly ILogger _logger = logger; @@ -51,6 +57,8 @@ public class ConfigurationMigrator(ILogger logger, Transi serverConfigService.Current.Version = 2; serverConfigService.Save(); } + + MigrateTempCollectionConfig(tempCollectionConfigService, lightlessConfigService); } public Task StartAsync(CancellationToken cancellationToken) @@ -63,4 +71,273 @@ public class ConfigurationMigrator(ILogger logger, Transi { return Task.CompletedTask; } + + private void MigrateTempCollectionConfig(TempCollectionConfigService tempCollectionConfigService, LightlessConfigService lightlessConfigService) + { + var now = DateTime.UtcNow; + TempCollectionConfig tempConfig = tempCollectionConfigService.Current; + var tempChanged = false; + var tempNeedsSave = false; + + if (TryReadTempCollectionData(lightlessConfigService.ConfigurationPath, out var root, out var ids, out var entries)) + { + tempChanged |= MergeTempCollectionData(tempConfig, ids, entries, now); + var removed = root.Remove("OrphanableTempCollections"); + removed |= root.Remove("OrphanableTempCollectionEntries"); + if (removed) + { + try + { + string updatedJson = root.ToJsonString(new JsonSerializerOptions + { + WriteIndented = true + }); + File.WriteAllText(lightlessConfigService.ConfigurationPath, updatedJson); + lightlessConfigService.UpdateLastWriteTime(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to rewrite {config} after temp collection migration", lightlessConfigService.ConfigurationPath); + return; + } + } + + if (ids.Count > 0 || entries.Count > 0) + { + _logger.LogInformation("Migrated {ids} temp collection ids and {entries} entries to {configName}", + ids.Count, entries.Count, tempCollectionConfigService.ConfigurationName); + } + } + + if (TryReadTempCollectionData(tempCollectionConfigService.ConfigurationPath, out var tempRoot, out var tempIds, out var tempEntries)) + { + tempChanged |= MergeTempCollectionData(tempConfig, tempIds, tempEntries, now); + if (tempRoot.Remove("OrphanableTempCollections")) + { + tempNeedsSave = true; + } + } + + if (tempChanged || tempNeedsSave) + { + tempCollectionConfigService.Save(); + } + } + + private bool TryReadTempCollectionData(string configPath, out JsonObject root, out HashSet ids, out List entries) + { + root = new JsonObject(); + ids = []; + entries = []; + + if (!File.Exists(configPath)) + { + return false; + } + + try + { + root = JsonNode.Parse(File.ReadAllText(configPath)) as JsonObject ?? new JsonObject(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read temp collection data from {config}", configPath); + return false; + } + + root.TryGetPropertyValue("OrphanableTempCollections", out JsonNode? idsNode); + root.TryGetPropertyValue("OrphanableTempCollectionEntries", out JsonNode? entriesNode); + + if (idsNode == null && entriesNode == null) + { + return false; + } + + ids = ParseGuidSet(idsNode); + entries = ParseEntries(entriesNode); + return true; + } + + private static HashSet ParseGuidSet(JsonNode? node) + { + HashSet ids = []; + if (node is not JsonArray array) + { + return ids; + } + + foreach (JsonNode? item in array) + { + Guid id = ParseGuid(item); + if (id != Guid.Empty) + { + ids.Add(id); + } + } + + return ids; + } + + private static List ParseEntries(JsonNode? node) + { + List entries = []; + if (node is not JsonArray array) + { + return entries; + } + + foreach (JsonNode? item in array) + { + if (item is not JsonObject obj) + { + continue; + } + + Guid id = ParseGuid(obj["Id"]); + if (id == Guid.Empty) + { + continue; + } + + DateTime registeredAtUtc = DateTime.MinValue; + if (TryParseDateTime(obj["RegisteredAtUtc"], out DateTime parsed)) + { + registeredAtUtc = parsed; + } + + entries.Add(new OrphanableTempCollectionEntry + { + Id = id, + RegisteredAtUtc = registeredAtUtc + }); + } + + return entries; + } + + private static Guid ParseGuid(JsonNode? node) + { + if (node is JsonValue value) + { + if (value.TryGetValue(out string? stringValue) && Guid.TryParse(stringValue, out Guid parsed)) + { + return parsed; + } + } + + return Guid.Empty; + } + + private static bool TryParseDateTime(JsonNode? node, out DateTime value) + { + value = DateTime.MinValue; + if (node is not JsonValue val) + { + return false; + } + + if (val.TryGetValue(out DateTime direct)) + { + value = direct; + return true; + } + + if (val.TryGetValue(out string? stringValue) + && DateTime.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out DateTime parsed)) + { + value = parsed; + return true; + } + + return false; + } + + private static bool MergeTempCollectionData(TempCollectionConfig config, HashSet ids, List entries, DateTime now) + { + bool changed = false; + Dictionary entryLookup = new(); + for (var i = config.OrphanableTempCollectionEntries.Count - 1; i >= 0; i--) + { + var entry = config.OrphanableTempCollectionEntries[i]; + if (entry.Id == Guid.Empty) + { + config.OrphanableTempCollectionEntries.RemoveAt(i); + changed = true; + continue; + } + + if (entryLookup.TryGetValue(entry.Id, out var existing)) + { + if (entry.RegisteredAtUtc != DateTime.MinValue + && (existing.RegisteredAtUtc == DateTime.MinValue || entry.RegisteredAtUtc < existing.RegisteredAtUtc)) + { + existing.RegisteredAtUtc = entry.RegisteredAtUtc; + changed = true; + } + + config.OrphanableTempCollectionEntries.RemoveAt(i); + changed = true; + continue; + } + + entryLookup[entry.Id] = entry; + } + + foreach (OrphanableTempCollectionEntry entry in entries) + { + if (entry.Id == Guid.Empty) + { + continue; + } + + if (!entryLookup.TryGetValue(entry.Id, out OrphanableTempCollectionEntry? existing)) + { + var added = new OrphanableTempCollectionEntry + { + Id = entry.Id, + RegisteredAtUtc = entry.RegisteredAtUtc + }; + config.OrphanableTempCollectionEntries.Add(added); + entryLookup[entry.Id] = added; + changed = true; + continue; + } + + if (entry.RegisteredAtUtc != DateTime.MinValue + && (existing.RegisteredAtUtc == DateTime.MinValue || entry.RegisteredAtUtc < existing.RegisteredAtUtc)) + { + existing.RegisteredAtUtc = entry.RegisteredAtUtc; + changed = true; + } + } + + foreach (Guid id in ids) + { + if (id == Guid.Empty) + { + continue; + } + + if (!entryLookup.TryGetValue(id, out OrphanableTempCollectionEntry? existing)) + { + var added = new OrphanableTempCollectionEntry + { + Id = id, + RegisteredAtUtc = now + }; + config.OrphanableTempCollectionEntries.Add(added); + entryLookup[id] = added; + changed = true; + continue; + } + + if (existing.RegisteredAtUtc == DateTime.MinValue) + { + existing.RegisteredAtUtc = now; + changed = true; + } + } + + return changed; + } } diff --git a/LightlessSync/LightlessConfiguration/ConfigurationSaveService.cs b/LightlessSync/LightlessConfiguration/ConfigurationSaveService.cs index 6208dae..b6a097e 100644 --- a/LightlessSync/LightlessConfiguration/ConfigurationSaveService.cs +++ b/LightlessSync/LightlessConfiguration/ConfigurationSaveService.cs @@ -72,37 +72,41 @@ public class ConfigurationSaveService : IHostedService { _logger.LogTrace("Saving {configName}", config.ConfigurationName); var configDir = config.ConfigurationPath.Replace(config.ConfigurationName, string.Empty); + var isTempCollections = string.Equals(config.ConfigurationName, TempCollectionConfigService.ConfigName, StringComparison.OrdinalIgnoreCase); - try + if (!isTempCollections) { - var configBackupFolder = Path.Join(configDir, BackupFolder); - if (!Directory.Exists(configBackupFolder)) - Directory.CreateDirectory(configBackupFolder); - - var configNameSplit = config.ConfigurationName.Split("."); - var existingConfigs = Directory.EnumerateFiles( - configBackupFolder, - configNameSplit[0] + "*") - .Select(c => new FileInfo(c)) - .OrderByDescending(c => c.LastWriteTime).ToList(); - if (existingConfigs.Skip(10).Any()) + try { - foreach (var oldBak in existingConfigs.Skip(10).ToList()) - { - oldBak.Delete(); - } - } + var configBackupFolder = Path.Join(configDir, BackupFolder); + if (!Directory.Exists(configBackupFolder)) + Directory.CreateDirectory(configBackupFolder); - string backupPath = Path.Combine(configBackupFolder, configNameSplit[0] + "." + DateTime.Now.ToString("yyyyMMddHHmmss") + "." + configNameSplit[1]); - _logger.LogTrace("Backing up current config to {backupPath}", backupPath); - File.Copy(config.ConfigurationPath, backupPath, overwrite: true); - FileInfo fi = new(backupPath); - fi.LastWriteTimeUtc = DateTime.UtcNow; - } - catch (Exception ex) - { - // ignore if file cannot be backupped - _logger.LogWarning(ex, "Could not create backup for {config}", config.ConfigurationPath); + var configNameSplit = config.ConfigurationName.Split("."); + var existingConfigs = Directory.EnumerateFiles( + configBackupFolder, + configNameSplit[0] + "*") + .Select(c => new FileInfo(c)) + .OrderByDescending(c => c.LastWriteTime).ToList(); + if (existingConfigs.Skip(10).Any()) + { + foreach (var oldBak in existingConfigs.Skip(10).ToList()) + { + oldBak.Delete(); + } + } + + string backupPath = Path.Combine(configBackupFolder, configNameSplit[0] + "." + DateTime.Now.ToString("yyyyMMddHHmmss") + "." + configNameSplit[1]); + _logger.LogTrace("Backing up current config to {backupPath}", backupPath); + File.Copy(config.ConfigurationPath, backupPath, overwrite: true); + FileInfo fi = new(backupPath); + fi.LastWriteTimeUtc = DateTime.UtcNow; + } + catch (Exception ex) + { + // ignore if file cannot be backupped + _logger.LogWarning(ex, "Could not create backup for {config}", config.ConfigurationPath); + } } var temp = config.ConfigurationPath + ".tmp"; @@ -110,7 +114,7 @@ public class ConfigurationSaveService : IHostedService { await File.WriteAllTextAsync(temp, JsonSerializer.Serialize(config.Current, typeof(T), new JsonSerializerOptions() { - WriteIndented = true + WriteIndented = !isTempCollections })).ConfigureAwait(false); File.Move(temp, config.ConfigurationPath, true); config.UpdateLastWriteTime(); diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index 4a2b2c9..c68f9aa 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -160,8 +160,6 @@ public class LightlessConfig : ILightlessConfiguration public string? SelectedFinderSyncshell { get; set; } = null; public string LastSeenVersion { get; set; } = string.Empty; public bool EnableParticleEffects { get; set; } = true; - public HashSet OrphanableTempCollections { get; set; } = []; - public List OrphanableTempCollectionEntries { get; set; } = []; public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Unsafe; public bool AnimationAllowOneBasedShift { get; set; } = false; public bool AnimationAllowNeighborIndexTolerance { get; set; } = false; diff --git a/LightlessSync/LightlessConfiguration/Configurations/TempCollectionConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/TempCollectionConfig.cs new file mode 100644 index 0000000..5f16e61 --- /dev/null +++ b/LightlessSync/LightlessConfiguration/Configurations/TempCollectionConfig.cs @@ -0,0 +1,10 @@ +using LightlessSync.LightlessConfiguration.Models; + +namespace LightlessSync.LightlessConfiguration.Configurations; + +[Serializable] +public sealed class TempCollectionConfig : ILightlessConfiguration +{ + public int Version { get; set; } = 1; + public List OrphanableTempCollectionEntries { get; set; } = []; +} diff --git a/LightlessSync/LightlessConfiguration/TempCollectionConfigService.cs b/LightlessSync/LightlessConfiguration/TempCollectionConfigService.cs new file mode 100644 index 0000000..a58a883 --- /dev/null +++ b/LightlessSync/LightlessConfiguration/TempCollectionConfigService.cs @@ -0,0 +1,12 @@ +using LightlessSync.LightlessConfiguration.Configurations; + +namespace LightlessSync.LightlessConfiguration; + +public sealed class TempCollectionConfigService : ConfigurationServiceBase +{ + public const string ConfigName = "tempcollections.json"; + + public TempCollectionConfigService(string configDir) : base(configDir) { } + + public override string ConfigurationName => ConfigName; +} diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index f14aeda..5affe17 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -429,6 +429,7 @@ public sealed class Plugin : IDalamudPlugin LightlessSync.UI.Style.MainStyle.Init(cfg, theme); return cfg; }); + services.AddSingleton(sp => new TempCollectionConfigService(configDir)); services.AddSingleton(sp => new ServerConfigService(configDir)); services.AddSingleton(sp => new NotesConfigService(configDir)); services.AddSingleton(sp => new PairTagConfigService(configDir)); @@ -442,6 +443,7 @@ public sealed class Plugin : IDalamudPlugin services.AddSingleton>(sp => sp.GetRequiredService()); services.AddSingleton>(sp => sp.GetRequiredService()); services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); services.AddSingleton>(sp => sp.GetRequiredService()); services.AddSingleton>(sp => sp.GetRequiredService()); services.AddSingleton>(sp => sp.GetRequiredService()); diff --git a/LightlessSync/Services/PenumbraTempCollectionJanitor.cs b/LightlessSync/Services/PenumbraTempCollectionJanitor.cs index 87d37ac..6f664de 100644 --- a/LightlessSync/Services/PenumbraTempCollectionJanitor.cs +++ b/LightlessSync/Services/PenumbraTempCollectionJanitor.cs @@ -10,15 +10,18 @@ namespace LightlessSync.Services; public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriberBase { private readonly IpcManager _ipc; - private readonly LightlessConfigService _config; + private readonly TempCollectionConfigService _config; + private readonly CancellationTokenSource _cleanupCts = new(); private int _ran; + private const int CleanupBatchSize = 50; + private static readonly TimeSpan CleanupBatchDelay = TimeSpan.FromMilliseconds(50); private static readonly TimeSpan OrphanCleanupDelay = TimeSpan.FromDays(1); public PenumbraTempCollectionJanitor( ILogger logger, LightlessMediator mediator, IpcManager ipc, - LightlessConfigService config) : base(logger, mediator) + TempCollectionConfigService config) : base(logger, mediator) { _ipc = ipc; _config = config; @@ -31,10 +34,6 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber if (id == Guid.Empty) return; var changed = false; var config = _config.Current; - if (config.OrphanableTempCollections.Add(id)) - { - changed = true; - } var now = DateTime.UtcNow; var existing = config.OrphanableTempCollectionEntries.FirstOrDefault(entry => entry.Id == id); @@ -63,8 +62,7 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber { if (id == Guid.Empty) return; var config = _config.Current; - var changed = config.OrphanableTempCollections.Remove(id); - changed |= RemoveEntry(config.OrphanableTempCollectionEntries, id) > 0; + var changed = RemoveEntry(config.OrphanableTempCollectionEntries, id) > 0; if (changed) { _config.Save(); @@ -79,14 +77,31 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber if (!_ipc.Penumbra.APIAvailable) return; + _ = Task.Run(async () => + { + try + { + await CleanupOrphansOnBootAsync(_cleanupCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + Logger.LogError(ex, "Error cleaning orphaned temp collections"); + } + }); + } + + private async Task CleanupOrphansOnBootAsync(CancellationToken token) + { var config = _config.Current; - var ids = config.OrphanableTempCollections; var entries = config.OrphanableTempCollectionEntries; - if (ids.Count == 0 && entries.Count == 0) + if (entries.Count == 0) return; var now = DateTime.UtcNow; - var changed = EnsureEntries(ids, entries, now); + var changed = EnsureEntryTimes(entries, now); var cutoff = now - OrphanCleanupDelay; var expired = entries .Where(entry => entry.Id != Guid.Empty && entry.RegisteredAtUtc != DateTime.MinValue && entry.RegisteredAtUtc <= cutoff) @@ -105,25 +120,47 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber var appId = Guid.NewGuid(); Logger.LogInformation("Cleaning up {count} orphaned Lightless temp collections older than {delay}", expired.Count, OrphanCleanupDelay); + List removedIds = []; foreach (var id in expired) { + if (token.IsCancellationRequested) + { + break; + } + try { - _ipc.Penumbra.RemoveTemporaryCollectionAsync(Logger, appId, id) - .GetAwaiter().GetResult(); + await _ipc.Penumbra.RemoveTemporaryCollectionAsync(Logger, appId, id).ConfigureAwait(false); } catch (Exception ex) { Logger.LogDebug(ex, "Failed removing orphaned temp collection {id}", id); } + + removedIds.Add(id); + if (removedIds.Count % CleanupBatchSize == 0) + { + try + { + await Task.Delay(CleanupBatchDelay, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } } - foreach (var id in expired) + if (removedIds.Count == 0) { - ids.Remove(id); + if (changed) + { + _config.Save(); + } + return; } - foreach (var id in expired) + foreach (var id in removedIds) { RemoveEntry(entries, id); } @@ -131,6 +168,17 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber _config.Save(); } + protected override void Dispose(bool disposing) + { + if (disposing) + { + _cleanupCts.Cancel(); + _cleanupCts.Dispose(); + } + + base.Dispose(disposing); + } + private static int RemoveEntry(List entries, Guid id) { var removed = 0; @@ -148,29 +196,9 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber return removed; } - private static bool EnsureEntries(HashSet ids, List entries, DateTime now) + private static bool EnsureEntryTimes(List entries, DateTime now) { var changed = false; - foreach (var id in ids) - { - if (id == Guid.Empty) - { - continue; - } - - if (entries.Any(entry => entry.Id == id)) - { - continue; - } - - entries.Add(new OrphanableTempCollectionEntry - { - Id = id, - RegisteredAtUtc = now - }); - changed = true; - } - foreach (var entry in entries) { if (entry.Id == Guid.Empty || entry.RegisteredAtUtc != DateTime.MinValue) -- 2.49.1 From 90bf84f8eb0cab3692f771f97f3d97575511b737 Mon Sep 17 00:00:00 2001 From: defnotken Date: Mon, 19 Jan 2026 10:58:37 -0600 Subject: [PATCH 81/87] Implement Lifestream With Location Sharing. --- .../Enums/TerritoryTypeIdHousing.cs | 51 +++++++ LightlessSync/Services/DalamudUtilService.cs | 16 ++ .../Services/LocationShareService.cs | 139 ++++++++++++++++++ LightlessSync/UI/Components/DrawUserPair.cs | 72 ++++++++- LightlessSync/UI/DrawEntityFactory.cs | 9 +- 5 files changed, 284 insertions(+), 3 deletions(-) create mode 100644 LightlessSync/Interop/InteropModel/Enums/TerritoryTypeIdHousing.cs diff --git a/LightlessSync/Interop/InteropModel/Enums/TerritoryTypeIdHousing.cs b/LightlessSync/Interop/InteropModel/Enums/TerritoryTypeIdHousing.cs new file mode 100644 index 0000000..699f6b6 --- /dev/null +++ b/LightlessSync/Interop/InteropModel/Enums/TerritoryTypeIdHousing.cs @@ -0,0 +1,51 @@ +namespace Lifestream.Enums; + +public enum TerritoryTypeIdHousing +{ + None = -1, + + // Mist (Limsa Lominsa) + Mist = 339, + MistSmall = 282, + MistMedium = 283, + MistLarge = 284, + MistFCRoom = 384, + MistFCWorkshop = 423, + MistApartment = 608, + + // Lavender Beds (Gridania) + Lavender = 340, + LavenderSmall = 342, + LavenderMedium = 343, + LavenderLarge = 344, + LavenderFCRoom = 385, + LavenderFCWorkshop = 425, + LavenderApartment = 609, + + // Goblet (Ul'dah) + Goblet = 341, + GobletSmall = 345, + GobletMedium = 346, + GobletLarge = 347, + GobletFCRoom = 386, + GobletFCWorkshop = 424, + GobletApartment = 610, + + // Shirogane (Kugane) + Shirogane = 641, + ShiroganeSmall = 649, + ShiroganeMedium = 650, + ShiroganeLarge = 651, + ShiroganeFCRoom = 652, + ShiroganeFCWorkshop = 653, + ShiroganeApartment = 655, + + // Empyreum (Ishgard) + Empyream = 979, + EmpyreamSmall = 980, + EmpyreamMedium = 981, + EmpyreamLarge = 982, + EmpyreamFCRoom = 983, + EmpyreamFCWorkshop = 984, + EmpyreamApartment = 999, +} \ No newline at end of file diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index d7a814a..500db53 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -701,7 +701,23 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber str += $" Room #{location.RoomId}"; } } + return str; + } + public string LocationToLifestream(LocationInfo location) + { + if (location.ServerId is 0 || location.TerritoryId is 0 || ContentFinderData.Value.ContainsKey(location.TerritoryId)) return String.Empty; + var str = WorldData.Value[(ushort)location.ServerId]; + if (location.HouseId is 0 && location.MapId is not 0) + { + var mapName = MapData.Value[(ushort)location.MapId].MapName; + var parts = mapName.Split(" - ", StringSplitOptions.RemoveEmptyEntries); + var locationName = parts.Length > 0 ? parts[^1] : mapName; + str += $", tp {locationName}"; + string message = $"LocationToLifestream: {str}"; + _logger.LogInformation(message); + + } return str; } diff --git a/LightlessSync/Services/LocationShareService.cs b/LightlessSync/Services/LocationShareService.cs index 38b2834..4a8249c 100644 --- a/LightlessSync/Services/LocationShareService.cs +++ b/LightlessSync/Services/LocationShareService.cs @@ -1,3 +1,4 @@ +using Lifestream.Enums; using LightlessSync.API.Data; using LightlessSync.API.Dto.CharaData; using LightlessSync.API.Dto.User; @@ -108,6 +109,144 @@ namespace LightlessSync.Services } } + public LocationInfo? GetLocationForLifestreamByUid(string uid) + { + try + { + if (_locations.TryGetValue(uid, out var location)) + { + return location; + } + return null; + } + catch (Exception e) + { + Logger.LogError(e,"GetLocationInfoByUid error : "); + throw; + } + } + + public AddressBookEntryTuple? GetAddressBookEntryByLocation(LocationInfo location) + { + if (location.ServerId is 0 || location.TerritoryId is 0) + { + return null; + } + + var territoryHousing = (TerritoryTypeIdHousing)location.TerritoryId; + + if (territoryHousing == TerritoryTypeIdHousing.None || !Enum.IsDefined(typeof(TerritoryTypeIdHousing), territoryHousing)) + { + return null; + } + + var city = GetResidentialAetheryteKind(territoryHousing); + + if (city == ResidentialAetheryteKind.None) + { + return null; + } + + if (location.HouseId is not 0 and not 100) + { + AddressBookEntryTuple addressEntry = ( + Name: "", + World: (int)location.ServerId, + City: (int)city, + Ward: (int)location.WardId, + PropertyType: 0, + Plot: (int)location.HouseId, + Apartment: 0, + ApartmentSubdivision: location.DivisionId == 2, + AliasEnabled: false, + Alias: "" + ); + return addressEntry; + } + else if (location.HouseId is 100) + { + AddressBookEntryTuple addressEntry = ( + Name: "", + World: (int)location.ServerId, + City: (int)city, + Ward: (int)location.WardId, + PropertyType: 1, + Plot: 0, + Apartment: (int)location.RoomId, + ApartmentSubdivision: location.DivisionId == 2, + AliasEnabled: false, + Alias: "" + ); + return addressEntry; + } + + return null; + } + + private ResidentialAetheryteKind GetResidentialAetheryteKind(TerritoryTypeIdHousing territoryHousing) + { + return territoryHousing switch + { + TerritoryTypeIdHousing.Shirogane or + TerritoryTypeIdHousing.ShiroganeApartment or + TerritoryTypeIdHousing.ShiroganeSmall or + TerritoryTypeIdHousing.ShiroganeMedium or + TerritoryTypeIdHousing.ShiroganeLarge or + TerritoryTypeIdHousing.ShiroganeFCRoom or + TerritoryTypeIdHousing.ShiroganeFCWorkshop + => ResidentialAetheryteKind.Kugane, + + TerritoryTypeIdHousing.Lavender or + TerritoryTypeIdHousing.LavenderSmall or + TerritoryTypeIdHousing.LavenderMedium or + TerritoryTypeIdHousing.LavenderLarge or + TerritoryTypeIdHousing.LavenderApartment or + TerritoryTypeIdHousing.LavenderFCRoom or + TerritoryTypeIdHousing.LavenderFCWorkshop + => ResidentialAetheryteKind.Gridania, + + TerritoryTypeIdHousing.Mist or + TerritoryTypeIdHousing.MistSmall or + TerritoryTypeIdHousing.MistMedium or + TerritoryTypeIdHousing.MistLarge or + TerritoryTypeIdHousing.MistApartment or + TerritoryTypeIdHousing.MistFCRoom or + TerritoryTypeIdHousing.MistFCWorkshop + => ResidentialAetheryteKind.Limsa, + + TerritoryTypeIdHousing.Goblet or + TerritoryTypeIdHousing.GobletSmall or + TerritoryTypeIdHousing.GobletMedium or + TerritoryTypeIdHousing.GobletLarge or + TerritoryTypeIdHousing.GobletApartment or + TerritoryTypeIdHousing.GobletFCRoom or + TerritoryTypeIdHousing.GobletFCWorkshop + => ResidentialAetheryteKind.Uldah, + + TerritoryTypeIdHousing.Empyream or + TerritoryTypeIdHousing.EmpyreamSmall or + TerritoryTypeIdHousing.EmpyreamMedium or + TerritoryTypeIdHousing.EmpyreamLarge or + TerritoryTypeIdHousing.EmpyreamApartment or + TerritoryTypeIdHousing.EmpyreamFCRoom or + TerritoryTypeIdHousing.EmpyreamFCWorkshop + => ResidentialAetheryteKind.Foundation, + + _ => ResidentialAetheryteKind.None + }; + } + + public string? GetMapAddressByLocation(LocationInfo location) + { + string? liString = null; + var territoryHousing = (TerritoryTypeIdHousing)location.TerritoryId; + if (GetResidentialAetheryteKind(territoryHousing) == ResidentialAetheryteKind.None) + { + liString = _dalamudUtilService.LocationToLifestream(location); + } + return liString; + } + public DateTimeOffset GetSharingStatus(string uid) { try diff --git a/LightlessSync/UI/Components/DrawUserPair.cs b/LightlessSync/UI/Components/DrawUserPair.cs index 3ee10ad..09a2517 100644 --- a/LightlessSync/UI/Components/DrawUserPair.cs +++ b/LightlessSync/UI/Components/DrawUserPair.cs @@ -4,8 +4,10 @@ using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; +using LightlessSync.API.Dto.CharaData; using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.User; +using LightlessSync.Interop.Ipc; using LightlessSync.LightlessConfiguration; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; @@ -40,6 +42,7 @@ public class DrawUserPair private readonly LocationShareService _locationShareService; private readonly CharaDataManager _charaDataManager; private readonly PairLedger _pairLedger; + private readonly IpcCallerLifestream _lifestreamIpc; private float _menuWidth = -1; private bool _wasHovered = false; private TooltipSnapshot _tooltipSnapshot = TooltipSnapshot.Empty; @@ -60,7 +63,8 @@ public class DrawUserPair LightlessConfigService configService, LocationShareService locationShareService, CharaDataManager charaDataManager, - PairLedger pairLedger) + PairLedger pairLedger, + IpcCallerLifestream lifestreamIpc) { _id = id; _uiEntry = uiEntry; @@ -79,6 +83,7 @@ public class DrawUserPair _locationShareService = locationShareService; _charaDataManager = charaDataManager; _pairLedger = pairLedger; + _lifestreamIpc = lifestreamIpc; } public PairDisplayEntry DisplayEntry => _displayEntry; @@ -656,6 +661,13 @@ public class DrawUserPair using (ImRaii.PushColor(ImGuiCol.Text, shareColor, shareLocation || shareLocationToOther)) _uiSharedService.IconText(shareLocationIcon); + var popupId = $"LocationPopup_{_pair.UserData.UID}"; + + if (ImGui.IsItemClicked(ImGuiMouseButton.Left) && shareLocation && !string.IsNullOrEmpty(location)) + { + ImGui.OpenPopup(popupId); + } + if (ImGui.IsItemHovered()) { ImGui.BeginTooltip(); @@ -669,6 +681,8 @@ public class DrawUserPair _uiSharedService.IconText(FontAwesomeIcon.LocationArrow); ImGui.SameLine(); ImGui.TextUnformatted(location); + ImGui.Separator(); + ImGui.TextUnformatted("Click to teleport to this location"); } else { @@ -700,6 +714,62 @@ public class DrawUserPair } ImGui.EndTooltip(); } + + if (ImGui.BeginPopup(popupId)) + { + + var locationInfo = _locationShareService.GetLocationForLifestreamByUid(_pair.UserData.UID); + if (locationInfo != null) + { + var locationLi = locationInfo.Value; + var housingAddress = _locationShareService.GetAddressBookEntryByLocation(locationLi); + var mapAddress = _locationShareService.GetMapAddressByLocation(locationLi); + ImGui.TextUnformatted("Teleport to user?"); + ImGui.Separator(); + if (!_lifestreamIpc.APIAvailable) + { + ImGui.TextUnformatted("Lifestream IPC is not available. Please ensure Lifestream is enabled"); + } + else if (housingAddress != null || mapAddress != null) + { + ImGui.TextUnformatted($"Go to {location}?"); + ImGui.TextUnformatted($"NOTE: Teleporting to maps with multiple aetherytes or instances may not be accurate currently. (ie. Thavnair, Yanxia)"); + } + else + { + ImGui.TextUnformatted("Lifestream cannot teleport here. If you are in a residential area, please make sure you're inside a plot."); + } + ImGui.Separator(); + if (_lifestreamIpc.APIAvailable && (housingAddress != null || mapAddress != null)) + { + if (locationLi.HouseId is not 0 && housingAddress != null) + { + if (ImGui.Button("Navigate")) + { + _lifestreamIpc.GoToHousingAddress(housingAddress.Value); + + ImGui.CloseCurrentPopup(); + } + } + else if (mapAddress != null && locationLi.HouseId is 0) + { + if (ImGui.Button("Navigate")) + { + _lifestreamIpc.ExecuteLifestreamCommand(mapAddress); + + ImGui.CloseCurrentPopup(); + } + } + + ImGui.SameLine(); + } + if (ImGui.Button("Close")) + { + ImGui.CloseCurrentPopup(); + } + ImGui.EndPopup(); + } + } } if (individualAnimDisabled || individualSoundsDisabled || individualVFXDisabled || individualIsSticky) diff --git a/LightlessSync/UI/DrawEntityFactory.cs b/LightlessSync/UI/DrawEntityFactory.cs index 08f81b6..b9613a5 100644 --- a/LightlessSync/UI/DrawEntityFactory.cs +++ b/LightlessSync/UI/DrawEntityFactory.cs @@ -16,6 +16,7 @@ using LightlessSync.UI.Handlers; using LightlessSync.UI.Models; using LightlessSync.WebAPI; using Microsoft.Extensions.Logging; +using LightlessSync.Interop.Ipc; namespace LightlessSync.UI; @@ -40,6 +41,7 @@ public class DrawEntityFactory private readonly IdDisplayHandler _uidDisplayHandler; private readonly PairLedger _pairLedger; private readonly PairFactory _pairFactory; + private readonly IpcCallerLifestream _lifestreamIpc; public DrawEntityFactory( ILogger logger, @@ -60,7 +62,8 @@ public class DrawEntityFactory RenameSyncshellTagUi renameSyncshellTagUi, SelectSyncshellForTagUi selectSyncshellForTagUi, PairLedger pairLedger, - PairFactory pairFactory) + PairFactory pairFactory, + IpcCallerLifestream lifestreamIpc) { _logger = logger; _apiController = apiController; @@ -81,6 +84,7 @@ public class DrawEntityFactory _selectSyncshellForTagUi = selectSyncshellForTagUi; _pairLedger = pairLedger; _pairFactory = pairFactory; + _lifestreamIpc = lifestreamIpc; } public DrawFolderGroup CreateGroupFolder( @@ -167,7 +171,8 @@ public class DrawEntityFactory _configService, _locationShareService, _charaDataManager, - _pairLedger); + _pairLedger, + _lifestreamIpc); } public IReadOnlyList GetAllEntries() -- 2.49.1 From 19a238c808c422c0c787f1b562a3bfb6c2b33186 Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 19 Jan 2026 19:24:44 +0100 Subject: [PATCH 82/87] Removal of unsafe and check if PTR is correctly aligning, checking in virtual query instead of memory --- .../PlayerData/Factories/PlayerDataFactory.cs | 85 ++++++++++++++----- 1 file changed, 63 insertions(+), 22 deletions(-) diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index 9fda2bd..69647a2 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -1,6 +1,5 @@ using Dalamud.Utility; -using FFXIVClientStructs.FFXIV.Client.Game.Character; -using LightlessSync.API.Data.Enum; +using FFXIVClientStructs.FFXIV.Client.Game.Object; using LightlessSync.FileCache; using LightlessSync.Interop.Ipc; using LightlessSync.LightlessConfiguration; @@ -14,6 +13,7 @@ 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; @@ -119,39 +119,48 @@ public class PlayerDataFactory return null; } + private static readonly int _drawObjectOffset = + (int)Marshal.OffsetOf(nameof(GameObject.DrawObject)); + private async Task CheckForNullDrawObject(IntPtr playerPointer) - => await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false); + => await _dalamudUtil.RunOnFrameworkThread(() => + { + nint basePtr = playerPointer; - private unsafe static bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer) + if (!LooksLikeUserPtr(basePtr)) + return true; + + nint drawObjAddr = basePtr + _drawObjectOffset; + + if (!TryReadIntPtr(drawObjAddr, out var drawObj)) + return true; + + return drawObj == 0; + }).ConfigureAwait(false); + + private static bool LooksLikeUserPtr(nint p) { - if (playerPointer == IntPtr.Zero) - return true; + if (p == 0) return false; - if (!IsPointerValid(playerPointer)) - return true; + ulong u = (ulong)p; - var character = (Character*)playerPointer; - if (character == null) - return true; + if (u < 0x0000_0001_0000UL) return false; + if (u > 0x0000_7FFF_FFFF_FFFFUL) return false; + if ((u & 0x7UL) != 0) return false; - var gameObject = &character->GameObject; - if (gameObject == null) - return true; - - if (!IsPointerValid((IntPtr)gameObject)) - return true; - - return gameObject->DrawObject == null; + return true; } - private static bool IsPointerValid(IntPtr ptr) + private static bool TryReadIntPtr(nint addr, out nint value) { - if (ptr == IntPtr.Zero) + value = 0; + + if (!VirtualReadable(addr)) return false; try { - _ = Marshal.ReadByte(ptr); + value = Marshal.ReadIntPtr(addr); return true; } catch @@ -160,6 +169,38 @@ public class PlayerDataFactory } } + private static bool VirtualReadable(nint addr) + { + if (VirtualQuery(addr, out var mbi, (nuint)Marshal.SizeOf()) == 0) + return false; + + const uint MEM_COMMIT = 0x1000; + const uint PAGE_NOACCESS = 0x01; + const uint PAGE_GUARD = 0x100; + + if (mbi.State != MEM_COMMIT) return false; + if ((mbi.Protect & PAGE_GUARD) != 0) return false; + if (mbi.Protect == PAGE_NOACCESS) return false; + + return true; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern nuint VirtualQuery(nint lpAddress, out MEMORY_BASIC_INFORMATION lpBuffer, nuint dwLength); + + + [StructLayout(LayoutKind.Sequential)] + private struct MEMORY_BASIC_INFORMATION + { + public nint BaseAddress; + public nint AllocationBase; + public uint AllocationProtect; + public nuint RegionSize; + public uint State; + public uint Protect; + public uint Type; + } + private static bool IsCacheFresh(CacheEntry entry) => (DateTime.UtcNow - entry.CreatedUtc) <= _characterCacheTtl; -- 2.49.1 From 367af2c3d0923c6a3f3959867150b442f82ffe03 Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 19 Jan 2026 19:26:00 +0100 Subject: [PATCH 83/87] Remove blankspace --- LightlessSync/PlayerData/Factories/PlayerDataFactory.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index 69647a2..5d304bd 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -188,7 +188,6 @@ public class PlayerDataFactory [DllImport("kernel32.dll", SetLastError = true)] private static extern nuint VirtualQuery(nint lpAddress, out MEMORY_BASIC_INFORMATION lpBuffer, nuint dwLength); - [StructLayout(LayoutKind.Sequential)] private struct MEMORY_BASIC_INFORMATION { -- 2.49.1 From cff866dcc260d4daee096e4066abda69b65d3dcd Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 20 Jan 2026 00:24:42 +0100 Subject: [PATCH 84/87] Added ptrguard to be used whenever ptrs are being used. --- .../PlayerData/Factories/PlayerDataFactory.cs | 72 ++-------- .../PlayerData/Handlers/GameObjectHandler.cs | 126 ++++++++++++------ LightlessSync/Utils/PtrGuard.cs | 55 ++++++++ LightlessSync/Utils/PtrGuardMemory.cs | 36 +++++ 4 files changed, 183 insertions(+), 106 deletions(-) create mode 100644 LightlessSync/Utils/PtrGuard.cs create mode 100644 LightlessSync/Utils/PtrGuardMemory.cs diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index 5d304bd..d960e2e 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -127,79 +127,23 @@ public class PlayerDataFactory { nint basePtr = playerPointer; - if (!LooksLikeUserPtr(basePtr)) + if (!PtrGuard.LooksLikePtr(basePtr)) return true; nint drawObjAddr = basePtr + _drawObjectOffset; - if (!TryReadIntPtr(drawObjAddr, out var drawObj)) + if (!PtrGuard.IsReadable(drawObjAddr, (nuint)IntPtr.Size)) + return true; + + if (!PtrGuard.TryReadIntPtr(drawObjAddr, out var drawObj)) + return true; + + if (drawObj != 0 && !PtrGuard.LooksLikePtr(drawObj)) return true; return drawObj == 0; }).ConfigureAwait(false); - private static bool LooksLikeUserPtr(nint p) - { - if (p == 0) return false; - - ulong u = (ulong)p; - - if (u < 0x0000_0001_0000UL) return false; - if (u > 0x0000_7FFF_FFFF_FFFFUL) return false; - if ((u & 0x7UL) != 0) return false; - - return true; - } - - private static bool TryReadIntPtr(nint addr, out nint value) - { - value = 0; - - if (!VirtualReadable(addr)) - return false; - - try - { - value = Marshal.ReadIntPtr(addr); - return true; - } - catch - { - return false; - } - } - - private static bool VirtualReadable(nint addr) - { - if (VirtualQuery(addr, out var mbi, (nuint)Marshal.SizeOf()) == 0) - return false; - - const uint MEM_COMMIT = 0x1000; - const uint PAGE_NOACCESS = 0x01; - const uint PAGE_GUARD = 0x100; - - if (mbi.State != MEM_COMMIT) return false; - if ((mbi.Protect & PAGE_GUARD) != 0) return false; - if (mbi.Protect == PAGE_NOACCESS) return false; - - return true; - } - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern nuint VirtualQuery(nint lpAddress, out MEMORY_BASIC_INFORMATION lpBuffer, nuint dwLength); - - [StructLayout(LayoutKind.Sequential)] - private struct MEMORY_BASIC_INFORMATION - { - public nint BaseAddress; - public nint AllocationBase; - public uint AllocationProtect; - public nuint RegionSize; - public uint State; - public uint Protect; - public uint Type; - } - private static bool IsCacheFresh(CacheEntry entry) => (DateTime.UtcNow - entry.CreatedUtc) <= _characterCacheTtl; diff --git a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs index 28b67b6..6ecd223 100644 --- a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs +++ b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs @@ -2,11 +2,12 @@ using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; using LightlessSync.Services; using LightlessSync.Services.Mediator; +using LightlessSync.Utils; using Microsoft.Extensions.Logging; using System.Runtime.CompilerServices; using static FFXIVClientStructs.FFXIV.Client.Game.Character.DrawDataContainer; -using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags; using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind; +using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags; namespace LightlessSync.PlayerData.Handlers; @@ -177,18 +178,47 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP var prevDrawObj = DrawObjectAddress; string? nameString = null; - Address = _getAddress(); + // Resolve address and validate BEFORE first deref + var nextAddr = _getAddress(); + + // Optional: catch the root cause quickly + // if nextAddr is 32-bit-ish, you're being fed an id/sentinel + if (nextAddr != IntPtr.Zero && !PtrGuard.LooksLikePtr(nextAddr)) + { + Logger.LogWarning("[{this}] _getAddress returned non-pointer: 0x{addr:X}", this, (ulong)nextAddr); + nextAddr = IntPtr.Zero; + } + + // Must be readable at least for a GameObject header before touching it + if (nextAddr != IntPtr.Zero && + !PtrGuard.IsReadable(nextAddr, (nuint)sizeof(FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject))) + { + Logger.LogWarning("[{this}] Address not readable: 0x{addr:X}", this, (ulong)nextAddr); + nextAddr = IntPtr.Zero; + } + + Address = nextAddr; if (Address != IntPtr.Zero) { var gameObject = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address; - DrawObjectAddress = (IntPtr)gameObject->DrawObject; + + var draw = (nint)gameObject->DrawObject; + + if (!PtrGuard.LooksLikePtr(draw) || !PtrGuard.IsReadable(draw, (nuint)sizeof(DrawObject))) + draw = 0; + + DrawObjectAddress = draw; EntityId = gameObject->EntityId; - var chara = (Character*)Address; - nameString = chara->GameObject.NameString; - if (!string.IsNullOrEmpty(nameString) && !string.Equals(nameString, Name, StringComparison.Ordinal)) - Name = nameString; + if (PtrGuard.IsReadable(Address, (nuint)sizeof(Character))) + { + var chara = (Character*)Address; + nameString = chara->GameObject.NameString; + + if (!string.IsNullOrEmpty(nameString) && !string.Equals(nameString, Name, StringComparison.Ordinal)) + Name = nameString; + } } else { @@ -196,22 +226,27 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP EntityId = uint.MaxValue; } - CurrentDrawCondition = IsBeingDrawnUnsafe(); + CurrentDrawCondition = (Address != IntPtr.Zero && DrawObjectAddress != IntPtr.Zero) + ? IsBeingDrawnUnsafe() + : DrawCondition.DrawObjectZero; if (_haltProcessing || !allowPublish) return; bool drawObjDiff = DrawObjectAddress != prevDrawObj; bool addrDiff = Address != prevAddr; - if (Address != IntPtr.Zero && DrawObjectAddress != IntPtr.Zero) + if (Address != IntPtr.Zero && DrawObjectAddress != IntPtr.Zero + && PtrGuard.IsReadable(Address, (nuint)sizeof(Character)) + && PtrGuard.IsReadable(DrawObjectAddress, (nuint)sizeof(DrawObject))) { var chara = (Character*)Address; var drawObj = (DrawObject*)DrawObjectAddress; + var objType = drawObj->Object.GetObjectType(); var isHuman = objType == ObjectType.CharacterBase && ((CharacterBase*)drawObj)->GetModelType() == CharacterBase.ModelType.Human; - nameString ??= ((Character*)Address)->GameObject.NameString; + nameString ??= chara->GameObject.NameString; var nameChange = !string.Equals(nameString, Name, StringComparison.Ordinal); if (nameChange) Name = nameString; @@ -219,32 +254,36 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP if (isHuman) { - var classJob = chara->CharacterData.ClassJob; - if (classJob != _classJob) + if (PtrGuard.IsReadable(DrawObjectAddress, (nuint)sizeof(Human))) { - Logger.LogTrace("[{this}] classjob changed from {old} to {new}", this, _classJob, classJob); - _classJob = classJob; - Mediator.Publish(new ClassJobChangedMessage(this)); + var classJob = chara->CharacterData.ClassJob; + if (classJob != _classJob) + { + Logger.LogTrace("[{this}] classjob changed from {old} to {new}", this, _classJob, classJob); + _classJob = classJob; + Mediator.Publish(new ClassJobChangedMessage(this)); + } + + equipDiff = CompareAndUpdateEquipByteData((byte*)&((Human*)drawObj)->Head); + + ref var mh = ref chara->DrawData.Weapon(WeaponSlot.MainHand); + ref var oh = ref chara->DrawData.Weapon(WeaponSlot.OffHand); + + equipDiff |= CompareAndUpdateMainHand((Weapon*)mh.DrawObject); + equipDiff |= CompareAndUpdateOffHand((Weapon*)oh.DrawObject); + } + else + { + isHuman = false; } - - equipDiff = CompareAndUpdateEquipByteData((byte*)&((Human*)drawObj)->Head); - - ref var mh = ref chara->DrawData.Weapon(WeaponSlot.MainHand); - ref var oh = ref chara->DrawData.Weapon(WeaponSlot.OffHand); - equipDiff |= CompareAndUpdateMainHand((Weapon*)mh.DrawObject); - equipDiff |= CompareAndUpdateOffHand((Weapon*)oh.DrawObject); - - if (equipDiff) - Logger.LogTrace("Checking [{this}] equip data as human from draw obj, result: {diff}", this, equipDiff); } - else + + if (!isHuman) { equipDiff = CompareAndUpdateEquipByteData((byte*)Unsafe.AsPointer(ref chara->DrawData.EquipmentModelIds[0])); - if (equipDiff) - Logger.LogTrace("Checking [{this}] equip data from game obj, result: {diff}", this, equipDiff); } - if (equipDiff && !_isOwnedObject) // send the message out immediately and cancel out, no reason to continue if not self + if (equipDiff && !_isOwnedObject) { Logger.LogTrace("[{this}] Changed", this); return; @@ -252,11 +291,13 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP bool customizeDiff = false; - if (isHuman) + if (isHuman && PtrGuard.IsReadable(DrawObjectAddress, (nuint)sizeof(Human))) { - var gender = ((Human*)drawObj)->Customize.Sex; - var raceId = ((Human*)drawObj)->Customize.Race; - var tribeId = ((Human*)drawObj)->Customize.Tribe; + var human = (Human*)drawObj; + + var gender = human->Customize.Sex; + var raceId = human->Customize.Race; + var tribeId = human->Customize.Tribe; if (_isOwnedObject && ObjectKind == ObjectKind.Player && (gender != Gender || raceId != RaceId || tribeId != TribeId)) @@ -267,15 +308,11 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP TribeId = tribeId; } - customizeDiff = CompareAndUpdateCustomizeData(((Human*)drawObj)->Customize.Data); - if (customizeDiff) - Logger.LogTrace("Checking [{this}] customize data as human from draw obj, result: {diff}", this, customizeDiff); + customizeDiff = CompareAndUpdateCustomizeData(human->Customize.Data); } else { customizeDiff = CompareAndUpdateCustomizeData(chara->DrawData.CustomizeData.Data); - if (customizeDiff) - Logger.LogTrace("Checking [{this}] customize data from game obj, result: {diff}", this, equipDiff); } if ((addrDiff || drawObjDiff || equipDiff || customizeDiff || nameChange) && _isOwnedObject) @@ -289,12 +326,11 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP CurrentDrawCondition = DrawCondition.DrawObjectZero; Logger.LogTrace("[{this}] Changed", this); if (_isOwnedObject && ObjectKind != ObjectKind.Player) - { Mediator.Publish(new ClearCacheForObjectMessage(this)); - } } } + private unsafe bool CompareAndUpdateCustomizeData(Span customizeData) { bool hasChanges = false; @@ -330,7 +366,10 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP private unsafe bool CompareAndUpdateMainHand(Weapon* weapon) { - if ((nint)weapon == nint.Zero) return false; + var p = (nint)weapon; + if (!PtrGuard.LooksLikePtr(p) || !PtrGuard.IsReadable(p, (nuint)sizeof(Weapon))) + return false; + bool hasChanges = false; hasChanges |= weapon->ModelSetId != MainHandData[0]; MainHandData[0] = weapon->ModelSetId; @@ -343,7 +382,10 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP private unsafe bool CompareAndUpdateOffHand(Weapon* weapon) { - if ((nint)weapon == nint.Zero) return false; + var p = (nint)weapon; + if (!PtrGuard.LooksLikePtr(p) || !PtrGuard.IsReadable(p, (nuint)sizeof(Weapon))) + return false; + bool hasChanges = false; hasChanges |= weapon->ModelSetId != OffHandData[0]; OffHandData[0] = weapon->ModelSetId; diff --git a/LightlessSync/Utils/PtrGuard.cs b/LightlessSync/Utils/PtrGuard.cs new file mode 100644 index 0000000..2b08169 --- /dev/null +++ b/LightlessSync/Utils/PtrGuard.cs @@ -0,0 +1,55 @@ +using System.Runtime.InteropServices; +using static LightlessSync.Utils.PtrGuardMemory; + +namespace LightlessSync.Utils +{ + public static partial class PtrGuard + { + private const ulong _minLikelyPtr = 0x0000_0001_0000_0000UL; + private const ulong _maxUserPtr = 0x0000_7FFF_FFFF_FFFFUL; + private const ulong _aligmentPtr = 0x7UL; + + public static bool LooksLikePtr(nint p) + { + if (p == 0) return false; + var u = (ulong)p; + if (u < _minLikelyPtr) return false; + if (u > _maxUserPtr) return false; + if ((u & _aligmentPtr) != 0) return false; + return true; + } + public static bool TryReadIntPtr(nint addr, out nint value) + { + value = 0; + + if (!LooksLikePtr(addr)) + return false; + + return ReadProcessMemory(GetCurrentProcess(), addr, out value, (nuint)IntPtr.Size, out nuint bytesRead) + && bytesRead == (nuint)IntPtr.Size; + } + + public static bool IsReadable(nint addr, nuint size) + { + if (addr == 0 || size == 0) return false; + + if (VirtualQuery(addr, out var mbi, (nuint)Marshal.SizeOf()) == 0) + return false; + + const uint Commit = 0x1000; + const uint NoAccess = 0x01; + const uint PageGuard = 0x100; + + if (mbi.State != Commit) return false; + if ((mbi.Protect & PageGuard) != 0) return false; + if (mbi.Protect == NoAccess) return false; + + ulong start = (ulong)addr; + ulong end = start + size - 1; + ulong r0 = (ulong)mbi.BaseAddress; + ulong r1 = r0 + mbi.RegionSize - 1; + + return start >= r0 && end <= r1; + } + } +} \ No newline at end of file diff --git a/LightlessSync/Utils/PtrGuardMemory.cs b/LightlessSync/Utils/PtrGuardMemory.cs new file mode 100644 index 0000000..ff29c4f --- /dev/null +++ b/LightlessSync/Utils/PtrGuardMemory.cs @@ -0,0 +1,36 @@ +using System.Runtime.InteropServices; + +namespace LightlessSync.Utils +{ + internal static class PtrGuardMemory + { + [StructLayout(LayoutKind.Sequential)] + internal struct MEMORY_BASIC_INFORMATION + { + public nint BaseAddress; + public nint AllocationBase; + public uint AllocationProtect; + public nuint RegionSize; + public uint State; + public uint Protect; + public uint Type; + } + + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern nuint VirtualQuery( + nint lpAddress, + out MEMORY_BASIC_INFORMATION lpBuffer, + nuint dwLength); + + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern bool ReadProcessMemory( + nint hProcess, + nint lpBaseAddress, + out nint lpBuffer, + nuint nSize, + out nuint lpNumberOfBytesRead); + + [DllImport("kernel32.dll")] + internal static extern nint GetCurrentProcess(); + } +} \ No newline at end of file -- 2.49.1 From 22fe9901a4ab4d7894e3b5e8f171fda4fe8f27eb Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 20 Jan 2026 00:25:26 +0100 Subject: [PATCH 85/87] Fixed some issues. --- LightlessSync/PlayerData/Handlers/GameObjectHandler.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs index 6ecd223..359ec20 100644 --- a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs +++ b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs @@ -178,18 +178,14 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP var prevDrawObj = DrawObjectAddress; string? nameString = null; - // Resolve address and validate BEFORE first deref var nextAddr = _getAddress(); - // Optional: catch the root cause quickly - // if nextAddr is 32-bit-ish, you're being fed an id/sentinel if (nextAddr != IntPtr.Zero && !PtrGuard.LooksLikePtr(nextAddr)) { Logger.LogWarning("[{this}] _getAddress returned non-pointer: 0x{addr:X}", this, (ulong)nextAddr); nextAddr = IntPtr.Zero; } - // Must be readable at least for a GameObject header before touching it if (nextAddr != IntPtr.Zero && !PtrGuard.IsReadable(nextAddr, (nuint)sizeof(FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject))) { -- 2.49.1 From 994335c6b08f30078663e05ea3969d3cb69cb39c Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 20 Jan 2026 02:40:30 +0100 Subject: [PATCH 86/87] Moved to trace log --- LightlessSync/PlayerData/Handlers/GameObjectHandler.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs index 359ec20..3b12208 100644 --- a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs +++ b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs @@ -182,14 +182,14 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP if (nextAddr != IntPtr.Zero && !PtrGuard.LooksLikePtr(nextAddr)) { - Logger.LogWarning("[{this}] _getAddress returned non-pointer: 0x{addr:X}", this, (ulong)nextAddr); + Logger.LogTrace("[{this}] _getAddress returned non-pointer: 0x{addr:X}", this, (ulong)nextAddr); nextAddr = IntPtr.Zero; } if (nextAddr != IntPtr.Zero && !PtrGuard.IsReadable(nextAddr, (nuint)sizeof(FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject))) { - Logger.LogWarning("[{this}] Address not readable: 0x{addr:X}", this, (ulong)nextAddr); + Logger.LogTrace("[{this}] Address not readable: 0x{addr:X}", this, (ulong)nextAddr); nextAddr = IntPtr.Zero; } -- 2.49.1 From d8335eb04f509e7c3bd38215935cc8f5c1e2aaa6 Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 20 Jan 2026 02:44:50 +0100 Subject: [PATCH 87/87] Removal of log --- LightlessSync/PlayerData/Handlers/GameObjectHandler.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs index 3b12208..4b01917 100644 --- a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs +++ b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs @@ -182,14 +182,12 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP if (nextAddr != IntPtr.Zero && !PtrGuard.LooksLikePtr(nextAddr)) { - Logger.LogTrace("[{this}] _getAddress returned non-pointer: 0x{addr:X}", this, (ulong)nextAddr); nextAddr = IntPtr.Zero; } if (nextAddr != IntPtr.Zero && !PtrGuard.IsReadable(nextAddr, (nuint)sizeof(FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject))) { - Logger.LogTrace("[{this}] Address not readable: 0x{addr:X}", this, (ulong)nextAddr); nextAddr = IntPtr.Zero; } -- 2.49.1