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 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; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; using ImageSharpImage = SixLabors.ImageSharp.Image; namespace LightlessSync.UI; public class DataAnalysisUi : WindowMediatorSubscriberBase { private const float MinTextureFilterPaneWidth = 305f; private const float MaxTextureFilterPaneWidth = 405f; private const float MinTextureDetailPaneWidth = 480f; private const float MaxTextureDetailPaneWidth = 720f; 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; 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; private readonly ModelDecimationService _modelDecimationService; private readonly TextureCompressionService _textureCompressionService; private readonly TextureMetadataHelper _textureMetadataHelper; private readonly TextureProcessingQueue _processingQueue; 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(); private readonly List _storedPathsToRemove = []; private readonly Dictionary _filePathResolve = []; 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; 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; private int _modelDecimationCurrentProgress = 0; private int _modelDecimationTotalJobs = 0; private bool _hasUpdate = false; private bool _modalOpen = false; private bool _showModal = false; 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; private TextureConversionProgress? _debugConversionProgress; #endif private bool _showAlreadyAddedTransients = false; private bool _acknowledgeReview = false; private Task? _textureRowsBuildTask; private CancellationTokenSource? _textureRowsBuildCts; private ObjectKind _selectedObjectTab; 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, ModelDecimationService modelDecimationService, TextureCompressionService textureCompressionService, TextureMetadataHelper textureMetadataHelper, TextureProcessingQueue processingQueue) : base(logger, mediator, "Lightless Character Data Analysis", performanceCollectorService) { _characterAnalyzer = characterAnalyzer; _ipcManager = ipcManager; _uiSharedService = uiSharedService; _configService = configService; _playerPerformanceConfig = playerPerformanceConfig; _transientResourceManager = transientResourceManager; _transientConfigService = transientConfigService; _modelDecimationService = modelDecimationService; _textureCompressionService = textureCompressionService; _textureMetadataHelper = textureMetadataHelper; _processingQueue = processingQueue; Mediator.Subscribe(this, (_) => { _hasUpdate = true; }); WindowBuilder.For(this) .SetSizeConstraints(new Vector2(1240, 680), new Vector2(3840, 2160)) .Apply(); _conversionProgress.ProgressChanged += ConversionProgress_ProgressChanged; } protected override void DrawInternal() { HandleConversionModal(); RefreshAnalysisCache(); DrawContentTabs(); } private void HandleConversionModal() { bool hasConversion = _conversionTask != null; #if DEBUG bool showDebug = _debugCompressionModalOpen && !hasConversion; #else const bool showDebug = false; #endif if (!hasConversion && !showDebug) { return; } if (hasConversion && _conversionTask!.IsCompleted) { ResetConversionModalState(); if (!showDebug) { return; } } _showModal = true; if (ImGui.BeginPopupModal("Texture Compression in Progress", UiSharedService.PopupWindowFlags)) { DrawConversionModalContent(showDebug); ImGui.EndPopup(); } else { _modalOpen = false; } if (_showModal && !_modalOpen) { ImGui.OpenPopup("Texture Compression in Progress"); _modalOpen = true; } } private void DrawConversionModalContent(bool isDebugPreview) { 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.Clamp(progress.Completed + 1, 0, total) : Math.Clamp(_conversionCurrentFileProgress, 0, total); var percent = total > 0 ? Math.Clamp(completed / (float)total, 0f, 1f) : 0f; 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; 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)) { 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(); } } } 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))) { ImGui.ProgressBar(percent, new Vector2(-1f, 0f), $"{percent * 100f:0}%"); } 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() { _conversionTask = null; _showModal = false; _modalOpen = false; _lastConversionProgress = null; _conversionCurrentFileName = string.Empty; _conversionCurrentFileProgress = 0; _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) { return; } _cachedAnalysis = CloneAnalysis(_characterAnalyzer.LastAnalysis); _hasUpdate = false; InvalidateTextureRows(); PruneModelSelections(); } private void DrawContentTabs() { using var tabBar = ImRaii.TabBar("analysisRecordingTabBar"); DrawAnalysisTab(); DrawTransientFilesTab(); } private void DrawAnalysisTab() { using var tabItem = ImRaii.TabItem("Analysis"); if (!tabItem) { return; } using var id = ImRaii.PushId("analysis"); DrawAnalysis(); } private void DrawTransientFilesTab() { using var tabItem = ImRaii.TabItem("Transient Files"); if (!tabItem) { return; } using var tabbar = ImRaii.TabBar("transientData"); using (var transientData = ImRaii.TabItem("Stored Transient File Data")) { using var id = ImRaii.PushId("data"); if (transientData) { DrawStoredData(); } } using (var transientRecord = ImRaii.TabItem("Record Transient Data")) { using var id = ImRaii.PushId("recording"); if (transientRecord) { DrawRecording(); } } } private void DrawStoredData() { UiSharedService.DrawTree("What is this? (Explanation / Help)", () => { UiSharedService.TextWrapped("This tab allows you to see which transient files are attached to your character."); UiSharedService.TextWrapped("Transient files are files that cannot be resolved to your character permanently. Lightless gathers these files in the background while you execute animations, VFX, sound effects, etc."); UiSharedService.TextWrapped("When sending your character data to others, Lightless will combine the files listed in \"All Jobs\" and the corresponding currently used job."); UiSharedService.TextWrapped("The purpose of this tab is primarily informational for you to see which files you are carrying with you. You can remove added game paths, however if you are using the animations etc. again, " + "Lightless will automatically attach these after using them. If you disable associated mods in Penumbra, the associated entries here will also be deleted automatically."); }); ImGuiHelpers.ScaledDummy(5); var config = _transientConfigService.Current.TransientConfigs; Vector2 availableContentRegion = Vector2.Zero; DrawCharacterColumn(); ImGui.SameLine(); bool selectedData = config.TryGetValue(_selectedStoredCharacter, out var transientStorage) && transientStorage != null; DrawJobColumn(); ImGui.SameLine(); DrawAttachedFilesColumn(); return; void DrawCharacterColumn() { using (ImRaii.Group()) { ImGui.TextUnformatted("Character"); ImGui.Separator(); ImGuiHelpers.ScaledDummy(3); availableContentRegion = ImGui.GetContentRegionAvail(); using (ImRaii.ListBox("##characters", new Vector2(200, availableContentRegion.Y))) { foreach (var entry in config) { var name = entry.Key.Split("_"); if (!_uiSharedService.WorldData.TryGetValue(ushort.Parse(name[1]), out var worldname)) { continue; } bool isSelected = string.Equals(_selectedStoredCharacter, entry.Key, StringComparison.Ordinal); if (ImGui.Selectable(name[0] + " (" + worldname + ")", isSelected)) { _selectedStoredCharacter = entry.Key; _selectedJobEntry = string.Empty; ResetSelectionFilters(); } } } } } void DrawJobColumn() { using (ImRaii.Group()) { ImGui.TextUnformatted("Job"); ImGui.Separator(); ImGuiHelpers.ScaledDummy(3); using (ImRaii.ListBox("##data", new Vector2(150, availableContentRegion.Y))) { if (!selectedData) { return; } if (ImGui.Selectable("All Jobs", string.Equals(_selectedJobEntry, "alljobs", StringComparison.Ordinal))) { _selectedJobEntry = "alljobs"; } foreach (var job in transientStorage!.JobSpecificCache) { if (!_uiSharedService.JobData.TryGetValue(job.Key, out var jobName)) { continue; } if (ImGui.Selectable(jobName, string.Equals(_selectedJobEntry, job.Key.ToString(), StringComparison.Ordinal))) { _selectedJobEntry = job.Key.ToString(); ResetSelectionFilters(); } } } } } void DrawAttachedFilesColumn() { using (ImRaii.Group()) { var selectedList = string.Equals(_selectedJobEntry, "alljobs", StringComparison.Ordinal) ? config[_selectedStoredCharacter].GlobalPersistentCache : (string.IsNullOrEmpty(_selectedJobEntry) ? [] : config[_selectedStoredCharacter].JobSpecificCache[uint.Parse(_selectedJobEntry)]); ImGui.TextUnformatted($"Attached Files (Total Files: {selectedList.Count})"); ImGui.Separator(); ImGuiHelpers.ScaledDummy(3); using (ImRaii.Disabled(string.IsNullOrEmpty(_selectedJobEntry))) { var restContent = availableContentRegion.X - ImGui.GetCursorPosX(); using var group = ImRaii.Group(); if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowRight, "Resolve Game Paths to used File Paths")) { _ = Task.Run(async () => { if (!_ipcManager.Penumbra.APIAvailable) { return; } var paths = selectedList.ToArray(); var (forward, _) = await _ipcManager.Penumbra.ResolvePathsAsync(paths, Array.Empty()).ConfigureAwait(false); for (int i = 0; i < paths.Length && i < forward.Length; i++) { var result = forward[i]; if (string.IsNullOrEmpty(result)) { continue; } if (!_filePathResolve.TryAdd(paths[i], result)) { _filePathResolve[paths[i]] = result; } } }); } ImGui.SameLine(); if (_uiSharedService.IconTextButton(FontAwesomeIcon.Eraser, "Clear Game Path File Resolves")) { _filePathResolve.Clear(); } ImGui.SameLine(); using (ImRaii.Disabled(!_storedPathsToRemove.Any())) { if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Delete Selected Game Paths")) { foreach (var entry in _storedPathsToRemove) { config[_selectedStoredCharacter].GlobalPersistentCache.Remove(entry); foreach (var job in config[_selectedStoredCharacter].JobSpecificCache) { job.Value.Remove(entry); } } _storedPathsToRemove.Clear(); _transientConfigService.Save(); _transientResourceManager.RebuildSemiTransientResources(); _filterFilePath = string.Empty; _filterGamePath = string.Empty; } } ImGui.SameLine(); using (ImRaii.Disabled(!UiSharedService.CtrlPressed())) { if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear ALL Game Paths")) { selectedList.Clear(); _transientConfigService.Save(); _transientResourceManager.RebuildSemiTransientResources(); _filterFilePath = string.Empty; _filterGamePath = string.Empty; } } UiSharedService.AttachToolTip("Hold CTRL to delete all game paths from the displayed list" + UiSharedService.TooltipSeparator + "You usually do not need to do this. All animation and VFX data will be automatically handled through Lightless."); ImGuiHelpers.ScaledDummy(5); ImGuiHelpers.ScaledDummy(30); ImGui.SameLine(); ImGui.SetNextItemWidth((restContent - 30) / 2f); ImGui.InputTextWithHint("##filterGamePath", "Filter by Game Path", ref _filterGamePath, 255); ImGui.SameLine(); ImGui.SetNextItemWidth((restContent - 30) / 2f); ImGui.InputTextWithHint("##filterFilePath", "Filter by File Path", ref _filterFilePath, 255); using (var dataTable = ImRaii.Table("##table", 3, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.ScrollY | ImGuiTableFlags.RowBg)) { if (dataTable) { ImGui.TableSetupColumn("", ImGuiTableColumnFlags.WidthFixed, 30); ImGui.TableSetupColumn("Game Path", ImGuiTableColumnFlags.WidthFixed, (restContent - 30) / 2f); ImGui.TableSetupColumn("File Path", ImGuiTableColumnFlags.WidthFixed, (restContent - 30) / 2f); ImGui.TableSetupScrollFreeze(0, 1); ImGui.TableHeadersRow(); int id = 0; foreach (var entry in selectedList) { if (!string.IsNullOrWhiteSpace(_filterGamePath) && !entry.Contains(_filterGamePath, StringComparison.OrdinalIgnoreCase)) continue; bool hasFileResolve = _filePathResolve.TryGetValue(entry, out var filePath); if (hasFileResolve && !string.IsNullOrEmpty(_filterFilePath) && !filePath!.Contains(_filterFilePath, StringComparison.OrdinalIgnoreCase)) continue; using var imguiid = ImRaii.PushId(id++); ImGui.TableNextColumn(); bool isSelected = _storedPathsToRemove.Contains(entry, StringComparer.Ordinal); if (ImGui.Checkbox("##", ref isSelected)) { if (isSelected) _storedPathsToRemove.Add(entry); else _storedPathsToRemove.Remove(entry); } ImGui.TableNextColumn(); ImGui.TextUnformatted(entry); UiSharedService.AttachToolTip(entry + UiSharedService.TooltipSeparator + "Click to copy to clipboard"); if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) { ImGui.SetClipboardText(entry); } ImGui.TableNextColumn(); if (hasFileResolve) { ImGui.TextUnformatted(filePath ?? "Unk"); UiSharedService.AttachToolTip(filePath ?? "Unk" + UiSharedService.TooltipSeparator + "Click to copy to clipboard"); if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) { ImGui.SetClipboardText(filePath); } } else { ImGui.TextUnformatted("-"); UiSharedService.AttachToolTip("Resolve Game Paths to used File Paths to display the associated file paths."); } } } } } } } void ResetSelectionFilters() { _storedPathsToRemove.Clear(); _filePathResolve.Clear(); _filterFilePath = string.Empty; _filterGamePath = string.Empty; } } private void DrawRecording() { DrawRecordingHelpSection(); DrawRecordingControlButtons(); if (_transientResourceManager.IsTransientRecording) { DrawRecordingActiveWarning(); } ImGuiHelpers.ScaledDummy(5); DrawRecordingReviewControls(); ImGuiHelpers.ScaledDummy(5); DrawRecordedTransientsTable(); } private static void DrawRecordingHelpSection() { UiSharedService.DrawTree("What is this? (Explanation / Help)", () => { UiSharedService.TextWrapped("This tab allows you to attempt to fix mods that do not sync correctly, especially those with modded models and animations." + Environment.NewLine + Environment.NewLine + "To use this, start the recording, execute one or multiple emotes/animations you want to attempt to fix and check if new data appears in the table below." + Environment.NewLine + "If it doesn't, Lightless is not able to catch the data or already has recorded the animation files (check 'Show previously added transient files' to see if not all is already present)." + Environment.NewLine + Environment.NewLine + "For most animations, vfx, etc. it is enough to just run them once unless they have random variations. Longer animations do not require to play out in their entirety to be captured."); ImGuiHelpers.ScaledDummy(5); UiSharedService.DrawGroupedCenteredColorText("Important Note: If you need to fix an animation that should apply across multiple jobs, you need to repeat this process with at least one additional job, " + "otherwise the animation will only be fixed for the currently active job. This goes primarily for emotes that are used across multiple jobs.", UIColors.Get("LightlessYellow"), 800); ImGuiHelpers.ScaledDummy(5); UiSharedService.DrawGroupedCenteredColorText("WARNING: WHILE RECORDING TRANSIENT DATA, DO NOT CHANGE YOUR APPEARANCE, ENABLED MODS OR ANYTHING. JUST DO THE ANIMATION(S) OR WHATEVER YOU NEED DOING AND STOP THE RECORDING.", ImGuiColors.DalamudRed, 800); ImGuiHelpers.ScaledDummy(5); }); } private void DrawRecordingControlButtons() { using (ImRaii.Disabled(_transientResourceManager.IsTransientRecording)) { if (_uiSharedService.IconTextButton(FontAwesomeIcon.Play, "Start Transient Recording")) { _transientRecordCts.Cancel(); _transientRecordCts.Dispose(); _transientRecordCts = new(); _transientResourceManager.StartRecording(_transientRecordCts.Token); _acknowledgeReview = false; } } ImGui.SameLine(); using (ImRaii.Disabled(!_transientResourceManager.IsTransientRecording)) { if (_uiSharedService.IconTextButton(FontAwesomeIcon.Stop, "Stop Transient Recording")) { _transientRecordCts.Cancel(); } } } private void DrawRecordingActiveWarning() { ImGui.SameLine(); UiSharedService.ColorText($"RECORDING - Time Remaining: {_transientResourceManager.RecordTimeRemaining.Value}", UIColors.Get("LightlessYellow")); ImGuiHelpers.ScaledDummy(5); UiSharedService.DrawGroupedCenteredColorText("DO NOT CHANGE YOUR APPEARANCE OR MODS WHILE RECORDING, YOU CAN ACCIDENTALLY MAKE SOME OF YOUR APPEARANCE RELATED MODS PERMANENT.", ImGuiColors.DalamudRed, 800); } private void DrawRecordingReviewControls() { ImGui.Checkbox("Show previously added transient files in the recording", ref _showAlreadyAddedTransients); _uiSharedService.DrawHelpText("Use this only if you want to see what was previously already caught by Lightless"); ImGuiHelpers.ScaledDummy(5); using (ImRaii.Disabled(_transientResourceManager.IsTransientRecording || _transientResourceManager.RecordedTransients.All(k => !k.AddTransient) || !_acknowledgeReview)) { if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Recorded Transient Data")) { _transientResourceManager.SaveRecording(); _acknowledgeReview = false; } } ImGui.SameLine(); ImGui.Checkbox("I acknowledge I have reviewed the recorded data", ref _acknowledgeReview); if (_transientResourceManager.RecordedTransients.Any(k => !k.AlreadyTransient)) { ImGuiHelpers.ScaledDummy(5); UiSharedService.DrawGroupedCenteredColorText("Please review the recorded mod files before saving and deselect files that got into the recording on accident.", UIColors.Get("LightlessYellow")); ImGuiHelpers.ScaledDummy(5); } } private void DrawRecordedTransientsTable() { var width = ImGui.GetContentRegionAvail(); using var table = ImRaii.Table("Recorded Transients", 4, ImGuiTableFlags.ScrollY | ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg); if (!table) { return; } int id = 0; ImGui.TableSetupColumn("", ImGuiTableColumnFlags.WidthFixed, 30); ImGui.TableSetupColumn("Owner", ImGuiTableColumnFlags.WidthFixed, 100); ImGui.TableSetupColumn("Game Path", ImGuiTableColumnFlags.WidthFixed, (width.X - 30 - 100) / 2f); ImGui.TableSetupColumn("File Path", ImGuiTableColumnFlags.WidthFixed, (width.X - 30 - 100) / 2f); ImGui.TableSetupScrollFreeze(0, 1); ImGui.TableHeadersRow(); var transients = _transientResourceManager.RecordedTransients.ToList(); transients.Reverse(); foreach (var value in transients) { if (value.AlreadyTransient && !_showAlreadyAddedTransients) continue; using var imguiid = ImRaii.PushId(id++); if (value.AlreadyTransient) { ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); } ImGui.TableNextColumn(); bool addTransient = value.AddTransient; if (ImGui.Checkbox("##add", ref addTransient)) { value.AddTransient = addTransient; } ImGui.TableNextColumn(); ImGui.TextUnformatted(value.Owner.Name); ImGui.TableNextColumn(); ImGui.TextUnformatted(value.GamePath); UiSharedService.AttachToolTip(value.GamePath); ImGui.TableNextColumn(); ImGui.TextUnformatted(value.FilePath); UiSharedService.AttachToolTip(value.FilePath); if (value.AlreadyTransient) { ImGui.PopStyleColor(); } } } private void DrawAnalysis() { UiSharedService.DrawTree("What is this? (Explanation / Help)", () => { UiSharedService.TextWrapped("This tab shows you all files and their sizes that are currently in use through your character and associated entities in Lightless"); }); if (_cachedAnalysis!.Count == 0) return; EnsureTextureRows(); bool isAnalyzing = _characterAnalyzer.IsAnalysisRunning; if (isAnalyzing) { UiSharedService.ColorTextWrapped($"Analyzing {_characterAnalyzer.CurrentFile}/{_characterAnalyzer.TotalFiles}", UIColors.Get("LightlessYellow")); if (_uiSharedService.IconTextButton(FontAwesomeIcon.StopCircle, "Cancel analysis")) { _characterAnalyzer.CancelAnalyze(); } } else { if (_cachedAnalysis!.Any(c => c.Value.Any(f => !f.Value.IsComputed))) { UiSharedService.ColorTextWrapped("Some entries in the analysis have file size not determined yet, press the button below to analyze your current data", UIColors.Get("LightlessYellow")); if (_uiSharedService.IconTextButton(FontAwesomeIcon.PlayCircle, "Start analysis (missing entries)")) { _ = _characterAnalyzer.ComputeAnalysis(print: false); } } else { if (_uiSharedService.IconTextButton(FontAwesomeIcon.PlayCircle, "Start analysis (recalculate all entries)")) { _ = _characterAnalyzer.ComputeAnalysis(print: false, recalculate: true); } } } ImGui.Separator(); var totalFileCount = _cachedAnalysis!.Values.Sum(c => c.Values.Count); var totalActualSize = _cachedAnalysis.Sum(c => c.Value.Sum(entry => entry.Value.OriginalSize)); var totalCompressedSize = _cachedAnalysis.Sum(c => c.Value.Sum(entry => entry.Value.CompressedSize)); var totalTriangles = _cachedAnalysis.Sum(c => c.Value.Sum(entry => entry.Value.Triangles)); var breakdown = string.Join(Environment.NewLine, _cachedAnalysis.Values .SelectMany(f => f.Values) .GroupBy(f => f.FileType, StringComparer.Ordinal) .OrderBy(f => f.Key, StringComparer.Ordinal) .Select(f => $"{f.Key}: {f.Count()} files, size: {UiSharedService.ByteToString(f.Sum(v => v.OriginalSize))}, compressed: {UiSharedService.ByteToString(f.Sum(v => v.CompressedSize))}")); DrawAnalysisOverview(totalFileCount, totalActualSize, totalCompressedSize, totalTriangles, breakdown); using var tabbar = ImRaii.TabBar("objectSelection"); foreach (var kvp in _cachedAnalysis) { using var id = ImRaii.PushId(kvp.Key.ToString()); string tabText = kvp.Key.ToString(); if (kvp.Value.Any(f => !f.Value.IsComputed)) tabText += " (!)"; using var tab = ImRaii.TabItem(tabText + "###" + kvp.Key.ToString()); if (tab.Success) { var groupedfiles = kvp.Value.Select(v => v.Value).GroupBy(f => f.FileType, StringComparer.Ordinal).OrderBy(k => k.Key, StringComparer.Ordinal).ToList(); DrawObjectOverview(kvp.Key, kvp.Value, groupedfiles); if (_selectedObjectTab != kvp.Key) { _selectedHash = string.Empty; _selectedObjectTab = kvp.Key; _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.OrdinalIgnoreCase) && !string.Equals(g.Key, "mdl", StringComparison.OrdinalIgnoreCase)) .ToList(); if (!string.IsNullOrEmpty(_selectedFileTypeTab) && otherFileGroups.TrueForAll(g => !string.Equals(g.Key, _selectedFileTypeTab, StringComparison.Ordinal))) { _selectedFileTypeTab = string.Empty; } if (string.IsNullOrEmpty(_selectedFileTypeTab) && otherFileGroups.Count > 0) { _selectedFileTypeTab = otherFileGroups[0].Key; } DrawTextureWorkspace(kvp.Key, modelGroup, otherFileGroups); } } } public override void OnOpen() { _hasUpdate = true; _selectedHash = string.Empty; _selectedTextureKey = string.Empty; _selectedTextureKeys.Clear(); _textureSelections.Clear(); _selectedModelKeys.Clear(); ResetTextureFilters(); InvalidateTextureRows(); _conversionFailed = false; _modelDecimationFailed = 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) { base.Dispose(disposing); foreach (var preview in _texturePreviews.Values) { preview.Texture?.Dispose(); } _texturePreviews.Clear(); _textureRowsBuildCts?.Cancel(); _textureRowsBuildCts?.Dispose(); _conversionProgress.ProgressChanged -= ConversionProgress_ProgressChanged; _modelDecimationCts.CancelDispose(); } private void ConversionProgress_ProgressChanged(object? sender, TextureConversionProgress e) { _lastConversionProgress = e; _conversionTotalJobs = e.Total; _conversionCurrentFileName = Path.GetFileName(e.CurrentJob.OutputFile); _conversionCurrentFileProgress = Math.Min(e.Completed + 1, e.Total); } private void EnsureTextureRows() { if (_cachedAnalysis == null) { return; } if (_textureRowsDirty && _textureRowsBuildTask == null) { _textureRowsBuildCts?.Dispose(); _textureRowsBuildCts = new(); var snapshot = _cachedAnalysis; _textureRowsBuildTask = Task.Run(() => BuildTextureRows(snapshot, _textureRowsBuildCts.Token), _textureRowsBuildCts.Token); } if (_textureRowsBuildTask == null || !_textureRowsBuildTask.IsCompleted) { return; } var completedTask = _textureRowsBuildTask; _textureRowsBuildTask = null; _textureRowsBuildCts?.Dispose(); _textureRowsBuildCts = null; if (completedTask.IsCanceled) { return; } if (completedTask.IsFaulted) { _logger.LogWarning(completedTask.Exception, "Failed to build texture rows."); _textureRowsDirty = false; return; } ApplyTextureRowBuild(completedTask.Result); _textureRowsDirty = false; } private void ApplyTextureRowBuild(TextureRowBuildResult result) { _textureRows.Clear(); _textureRows.AddRange(result.Rows); foreach (var row in _textureRows) { if (row.IsAlreadyCompressed) { _selectedTextureKeys.Remove(row.Key); _textureSelections.Remove(row.Key); } } _selectedTextureKeys.RemoveWhere(key => !result.ValidKeys.Contains(key)); foreach (var key in _texturePreviews.Keys.ToArray()) { if (!result.ValidKeys.Contains(key) && _texturePreviews.TryGetValue(key, out var preview)) { preview.Texture?.Dispose(); _texturePreviews.Remove(key); } } foreach (var key in _textureResolutionCache.Keys.ToArray()) { if (!result.ValidKeys.Contains(key)) { _textureResolutionCache.Remove(key); } } foreach (var key in _textureSelections.Keys.ToArray()) { if (!result.ValidKeys.Contains(key)) { _textureSelections.Remove(key); continue; } _textureSelections[key] = _textureCompressionService.NormalizeTarget(_textureSelections[key]); } if (!string.IsNullOrEmpty(_selectedTextureKey) && !result.ValidKeys.Contains(_selectedTextureKey)) { _selectedTextureKey = string.Empty; } } 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) { var rows = new List(); HashSet validKeys = new(StringComparer.OrdinalIgnoreCase); foreach (var (objectKind, entries) in analysis) { foreach (var entry in entries.Values) { token.ThrowIfCancellationRequested(); if (!string.Equals(entry.FileType, "tex", StringComparison.Ordinal)) { continue; } if (entry.FilePaths.Count == 0) { continue; } var primaryFile = entry.FilePaths[0]; var duplicatePaths = entry.FilePaths.Skip(1).ToList(); var primaryGamePath = entry.GamePaths.FirstOrDefault() ?? string.Empty; var classificationPath = string.IsNullOrEmpty(primaryGamePath) ? primaryFile : primaryGamePath; var mapKind = _textureMetadataHelper.DetermineMapKind(primaryGamePath, primaryFile); var category = TextureMetadataHelper.DetermineCategory(classificationPath); var slot = TextureMetadataHelper.DetermineSlot(category, classificationPath); var format = entry.Format.Value; var suggestion = TextureMetadataHelper.GetSuggestedTarget(format, mapKind, classificationPath); TextureCompressionTarget? currentTarget = TextureMetadataHelper.TryMapFormatToTarget(format, out var mappedTarget) ? mappedTarget : null; var displayName = Path.GetFileName(primaryFile); var row = new TextureRow( objectKind, entry, primaryFile, duplicatePaths, entry.GamePaths.ToList(), primaryGamePath, format, mapKind, category, slot, displayName, currentTarget, suggestion?.Target, suggestion?.Reason); validKeys.Add(row.Key); rows.Add(row); } } rows.Sort((a, b) => { var comp = a.ObjectKind.CompareTo(b.ObjectKind); if (comp != 0) return comp; comp = string.Compare(a.Slot, b.Slot, StringComparison.OrdinalIgnoreCase); if (comp != 0) return comp; return string.Compare(a.DisplayName, b.DisplayName, StringComparison.OrdinalIgnoreCase); }); return new TextureRowBuildResult(rows, validKeys); } private void InvalidateTextureRows() { _textureRowsDirty = true; _textureRowsBuildCts?.Cancel(); _textureResolutionCache.Clear(); } private static string MakeTextureKey(ObjectKind objectKind, string primaryFilePath) => $"{objectKind}|{primaryFilePath}".ToLowerInvariant(); private void ResetTextureFilters() { _textureCategoryFilter = null; _textureSlotFilter = "All"; _textureMapFilter = null; _textureTargetFilter = null; _textureSearch = string.Empty; } private static Dictionary> CloneAnalysis( Dictionary> source) { var clone = new Dictionary>(source.Count); foreach (var (objectKind, entries) in source) { var entryClone = new Dictionary(entries.Count, entries.Comparer); foreach (var (hash, entry) in entries) { entryClone[hash] = new CharacterAnalyzer.FileDataEntry( hash: hash, fileType: entry.FileType, gamePaths: entry.GamePaths?.ToList() ?? [], filePaths: entry.FilePaths?.ToList() ?? [], originalSize: entry.OriginalSize, compressedSize: entry.CompressedSize, triangles: entry.Triangles, cacheEntries: entry.CacheEntries ); } clone[objectKind] = entryClone; } return clone; } private void DrawAnalysisOverview(int totalFiles, long totalActualSize, long totalCompressedSize, long totalTriangles, string breakdownTooltip) { var scale = ImGuiHelpers.GlobalScale; var accent = UIColors.Get("LightlessGreen"); 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 infoColor = ImGuiColors.DalamudGrey; var diff = totalActualSize - totalCompressedSize; string? diffText = null; Vector4? diffColor = null; if (diff > 0) { diffText = $"Saved {UiSharedService.ByteToString(diff)}"; diffColor = UIColors.Get("LightlessGreen"); } else if (diff < 0) { diffText = $"Over by {UiSharedService.ByteToString(Math.Abs(diff))}"; diffColor = UIColors.Get("DimRed"); } var summaryHeight = MathF.Max(ImGui.GetTextLineHeightWithSpacing() * 2.4f, 44f * 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("analysisOverview", new Vector2(-1f, summaryHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) { if (child) { using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(8f * scale, 4f * scale))) { if (ImGui.BeginTable("analysisOverviewTable", 4, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.PadOuterX | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.NoHostExtendX)) { ImGui.TableNextRow(); DrawSummaryCell(FontAwesomeIcon.ListUl, accent, $"{totalFiles:N0}", totalFiles == 1 ? "Tracked file" : "Tracked files", infoColor, scale, tooltip: breakdownTooltip); DrawSummaryCell(FontAwesomeIcon.FileArchive, ImGuiColors.DalamudGrey, UiSharedService.ByteToString(totalActualSize), "Actual size", infoColor, scale); DrawSummaryCell(FontAwesomeIcon.CompressArrowsAlt, UIColors.Get("LightlessYellow2"), UiSharedService.ByteToString(totalCompressedSize), "Compressed size", infoColor, scale, diffText, diffColor); DrawSummaryCell(FontAwesomeIcon.ChartLine, UIColors.Get("LightlessPurple"), totalTriangles.ToString("N0", CultureInfo.InvariantCulture), "Modded triangles", infoColor, scale); ImGui.EndTable(); } } } } ImGuiHelpers.ScaledDummy(6); } private void DrawObjectOverview( ObjectKind objectKind, IReadOnlyDictionary entries, IReadOnlyList> groupedFiles) { var scale = ImGuiHelpers.GlobalScale; var accent = 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 fileCount = entries.Count; var actualSize = entries.Sum(c => c.Value.OriginalSize); var compressedSize = entries.Sum(c => c.Value.CompressedSize); var triangles = entries.Sum(c => c.Value.Triangles); var breakdown = string.Join(Environment.NewLine, groupedFiles.Select(f => $"{f.Key}: {f.Count()} files, size: {UiSharedService.ByteToString(f.Sum(v => v.OriginalSize))}, compressed: {UiSharedService.ByteToString(f.Sum(v => v.CompressedSize))}")); var savings = actualSize - compressedSize; string? compressedExtra = null; Vector4? compressedExtraColor = null; if (savings > 0) { compressedExtra = $"Saved {UiSharedService.ByteToString(savings)}"; compressedExtraColor = UIColors.Get("LightlessGreen"); } else if (savings < 0) { compressedExtra = $"Over by {UiSharedService.ByteToString(Math.Abs(savings))}"; compressedExtraColor = UIColors.Get("DimRed"); } long actualVram = 0; var vramGroup = groupedFiles.SingleOrDefault(v => string.Equals(v.Key, "tex", StringComparison.Ordinal)); if (vramGroup != null) { actualVram = vramGroup.Sum(f => f.OriginalSize); } string? vramExtra = null; Vector4? vramExtraColor = null; var vramSub = vramGroup != null ? "VRAM usage" : "VRAM usage (no textures)"; var showThresholds = _playerPerformanceConfig.Current.WarnOnExceedingThresholds || _playerPerformanceConfig.Current.ShowPerformanceIndicator; if (showThresholds && actualVram > 0) { var thresholdBytes = Math.Max(0, _playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB) * 1024L * 1024L; if (thresholdBytes > 0) { if (actualVram > thresholdBytes) { vramExtra = $"Over by {UiSharedService.ByteToString(actualVram - thresholdBytes)}"; vramExtraColor = UIColors.Get("LightlessYellow"); } else { vramExtra = $"Remaining {UiSharedService.ByteToString(thresholdBytes - actualVram)}"; vramExtraColor = UIColors.Get("LightlessGreen"); } } } string? triExtra = null; Vector4? triExtraColor = null; if (showThresholds) { var triThreshold = Math.Max(0, _playerPerformanceConfig.Current.TrisWarningThresholdThousands) * 1000; if (triThreshold > 0) { if (triangles > triThreshold) { triExtra = $"Over by {(triangles - triThreshold).ToString("N0", CultureInfo.InvariantCulture)}"; triExtraColor = UIColors.Get("LightlessYellow"); } else { triExtra = $"Remaining {(triThreshold - triangles).ToString("N0", CultureInfo.InvariantCulture)}"; triExtraColor = UIColors.Get("LightlessGreen"); } } } var summaryHeight = MathF.Max(ImGui.GetTextLineHeightWithSpacing() * 2.4f, 46f * scale); var availableWidth = ImGui.GetContentRegionAvail().X; var summaryWidth = objectKind == ObjectKind.Player ? availableWidth : MathF.Min(availableWidth, 760f * 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(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($"objectOverview##{objectKind}", new Vector2(summaryWidth, summaryHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) { if (child) { using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(8f * scale, 4f * scale))) { if (ImGui.BeginTable($"objectOverviewTable##{objectKind}", 5, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.PadOuterX | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.NoHostExtendX)) { ImGui.TableNextRow(); DrawSummaryCell(FontAwesomeIcon.ClipboardList, accent, $"{fileCount:N0}", $"{objectKind} files", infoColor, scale, tooltip: breakdown); DrawSummaryCell(FontAwesomeIcon.FileArchive, ImGuiColors.DalamudGrey, UiSharedService.ByteToString(actualSize), "Actual size", infoColor, scale); DrawSummaryCell(FontAwesomeIcon.CompressArrowsAlt, UIColors.Get("LightlessYellow2"), UiSharedService.ByteToString(compressedSize), "Compressed size", infoColor, scale, compressedExtra, compressedExtraColor); DrawSummaryCell(FontAwesomeIcon.Memory, UIColors.Get("LightlessBlue"), UiSharedService.ByteToString(actualVram), vramSub, infoColor, scale, vramExtra, vramExtraColor); DrawSummaryCell(FontAwesomeIcon.ProjectDiagram, UIColors.Get("LightlessPurple"), triangles.ToString("N0", CultureInfo.InvariantCulture), "Modded triangles", infoColor, scale, triExtra, triExtraColor); ImGui.EndTable(); } } } } ImGuiHelpers.ScaledDummy(4); } private enum TextureWorkspaceTab { Textures, Models, OtherFiles } private sealed record TextureRow( ObjectKind ObjectKind, CharacterAnalyzer.FileDataEntry Entry, string PrimaryFilePath, IReadOnlyList DuplicateFilePaths, IReadOnlyList GamePaths, string PrimaryGamePath, string Format, TextureMapKind MapKind, TextureUsageCategory Category, string Slot, string DisplayName, TextureCompressionTarget? CurrentTarget, TextureCompressionTarget? SuggestedTarget, string? SuggestionReason) { public string Key { get; } = MakeTextureKey(ObjectKind, PrimaryFilePath); public string Hash => Entry.Hash; public long OriginalSize => Entry.OriginalSize; public long CompressedSize => Entry.CompressedSize; public bool IsComputed => Entry.IsComputed; public bool IsAlreadyCompressed => CurrentTarget.HasValue; } private sealed record TextureRowBuildResult( List Rows, HashSet ValidKeys); private sealed class TexturePreviewState { public Task? LoadTask { get; set; } public IDalamudTextureWrap? Texture { get; set; } public string? ErrorMessage { get; set; } public DateTime LastAccessUtc { get; set; } = DateTime.UtcNow; } private readonly struct TextureResolutionInfo { public TextureResolutionInfo(ushort width, ushort height, ushort depth, ushort mipLevels) { Width = width; Height = height; Depth = depth; MipLevels = mipLevels; } public ushort Width { get; } public ushort Height { get; } public ushort Depth { get; } public ushort MipLevels { get; } } private void DrawTextureWorkspace( ObjectKind objectKind, IGrouping? modelGroup, IReadOnlyList> otherFileGroups) { if (!_textureWorkspaceTabs.ContainsKey(objectKind)) { _textureWorkspaceTabs[objectKind] = TextureWorkspaceTab.Textures; } var hasModels = modelGroup != null; var hasOther = otherFileGroups.Count > 0; if (!hasModels && !hasOther) { _textureWorkspaceTabs[objectKind] = TextureWorkspaceTab.Textures; DrawTextureTabContent(objectKind); return; } using var tabBar = ImRaii.TabBar($"textureWorkspaceTabs##{objectKind}"); using (var texturesTab = ImRaii.TabItem($"Textures###textures_{objectKind}")) { if (texturesTab) { if (_textureWorkspaceTabs[objectKind] != TextureWorkspaceTab.Textures) { _textureWorkspaceTabs[objectKind] = TextureWorkspaceTab.Textures; } DrawTextureTabContent(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) { _textureWorkspaceTabs[objectKind] = TextureWorkspaceTab.OtherFiles; } DrawOtherFileWorkspace(otherFileGroups); } } } private void DrawTextureTabContent(ObjectKind objectKind) { var scale = ImGuiHelpers.GlobalScale; if (_textureRowsBuildTask != null && !_textureRowsBuildTask.IsCompleted && _textureRows.Count == 0) { UiSharedService.ColorText("Building texture list.", ImGuiColors.DalamudGrey); return; } var objectRows = _textureRows.Where(row => row.ObjectKind == objectKind).ToList(); var hasAnyTextureRows = objectRows.Count > 0; var availableCategories = objectRows.Select(row => row.Category) .Distinct() .OrderBy(c => c.ToString(), StringComparer.Ordinal) .ToList(); var availableSlots = objectRows .Select(row => row.Slot) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(s => s, StringComparer.OrdinalIgnoreCase) .ToList(); var availableMapKinds = objectRows.Select(row => row.MapKind) .Distinct() .OrderBy(m => m.ToString(), StringComparer.Ordinal) .ToList(); var totalTextureCount = objectRows.Count; var totalTextureOriginal = objectRows.Sum(row => row.OriginalSize); var totalTextureCompressed = objectRows.Sum(row => row.CompressedSize); IEnumerable filtered = objectRows; if (_textureCategoryFilter is { } categoryFilter) { filtered = filtered.Where(row => row.Category == categoryFilter); } if (!string.Equals(_textureSlotFilter, "All", StringComparison.Ordinal)) { filtered = filtered.Where(row => string.Equals(row.Slot, _textureSlotFilter, StringComparison.OrdinalIgnoreCase)); } if (_textureMapFilter is { } mapFilter) { filtered = filtered.Where(row => row.MapKind == mapFilter); } if (_textureTargetFilter is { } targetFilter) { filtered = filtered.Where(row => (row.CurrentTarget != null && row.CurrentTarget == targetFilter) || (row.CurrentTarget == null && row.SuggestedTarget == targetFilter)); } if (!string.IsNullOrWhiteSpace(_textureSearch)) { var term = _textureSearch.Trim(); filtered = filtered.Where(row => row.DisplayName.Contains(term, StringComparison.OrdinalIgnoreCase) || row.PrimaryGamePath.Contains(term, StringComparison.OrdinalIgnoreCase) || row.Hash.Contains(term, StringComparison.OrdinalIgnoreCase)); } var rows = filtered.ToList(); if (!string.IsNullOrEmpty(_selectedTextureKey) && rows.All(r => r.Key != _selectedTextureKey)) { _selectedTextureKey = rows.FirstOrDefault()?.Key ?? string.Empty; } var totalOriginal = rows.Sum(r => r.OriginalSize); var totalCompressed = rows.Sum(r => r.CompressedSize); var availableSize = ImGui.GetContentRegionAvail(); var windowPos = ImGui.GetWindowPos(); var spacingX = ImGui.GetStyle().ItemSpacing.X; var filterSplitterWidth = TextureFilterSplitterWidth * scale; var detailSplitterWidth = (_textureDetailCollapsed ? TextureDetailSplitterCollapsedWidth : TextureDetailSplitterWidth) * scale; var totalSplitterWidth = filterSplitterWidth + detailSplitterWidth; var totalSpacing = 2 * spacingX; const float minFilterWidth = MinTextureFilterPaneWidth; const float minDetailWidth = MinTextureDetailPaneWidth; const float minCenterWidth = 340f; var detailMinForLayout = _textureDetailCollapsed ? 0f : minDetailWidth; var dynamicFilterMax = Math.Max(minFilterWidth, availableSize.X - detailMinForLayout - minCenterWidth - totalSplitterWidth - totalSpacing); var filterMaxBound = Math.Min(MaxTextureFilterPaneWidth, dynamicFilterMax); var filterWidth = Math.Clamp(_textureFilterPaneWidth, minFilterWidth, filterMaxBound); var dynamicDetailMax = Math.Max(detailMinForLayout, availableSize.X - filterWidth - minCenterWidth - totalSplitterWidth - totalSpacing); var detailMaxBound = _textureDetailCollapsed ? 0f : Math.Min(MaxTextureDetailPaneWidth, dynamicDetailMax); var detailWidth = _textureDetailCollapsed ? 0f : Math.Clamp(_textureDetailPaneWidth, minDetailWidth, detailMaxBound); var centerWidth = availableSize.X - filterWidth - detailWidth - totalSplitterWidth - totalSpacing; if (centerWidth < minCenterWidth) { var deficit = minCenterWidth - centerWidth; if (!_textureDetailCollapsed) { detailWidth = Math.Clamp(detailWidth - deficit, minDetailWidth, Math.Min(MaxTextureDetailPaneWidth, Math.Max(minDetailWidth, availableSize.X - filterWidth - minCenterWidth - totalSplitterWidth - totalSpacing))); centerWidth = availableSize.X - filterWidth - detailWidth - totalSplitterWidth - totalSpacing; if (centerWidth < minCenterWidth) { deficit = minCenterWidth - centerWidth; filterWidth = Math.Clamp(filterWidth - deficit, minFilterWidth, Math.Min(MaxTextureFilterPaneWidth, Math.Max(minFilterWidth, availableSize.X - detailWidth - minCenterWidth - totalSplitterWidth - totalSpacing))); detailWidth = Math.Clamp(detailWidth, minDetailWidth, Math.Min(MaxTextureDetailPaneWidth, Math.Max(minDetailWidth, availableSize.X - filterWidth - minCenterWidth - totalSplitterWidth - totalSpacing))); centerWidth = availableSize.X - filterWidth - detailWidth - totalSplitterWidth - totalSpacing; if (centerWidth < minCenterWidth) { centerWidth = minCenterWidth; } } } else { filterWidth = Math.Clamp(filterWidth - deficit, minFilterWidth, Math.Min(MaxTextureFilterPaneWidth, Math.Max(minFilterWidth, availableSize.X - minCenterWidth - totalSplitterWidth - totalSpacing))); centerWidth = availableSize.X - filterWidth - detailWidth - totalSplitterWidth - totalSpacing; if (centerWidth < minCenterWidth) { centerWidth = minCenterWidth; } } } _textureFilterPaneWidth = filterWidth; if (!_textureDetailCollapsed) { _textureDetailPaneWidth = detailWidth; } ImGui.BeginGroup(); using (var filters = ImRaii.Child("textureFilters", new Vector2(filterWidth, 0), true)) { if (filters) { DrawTextureFilters( availableCategories, availableSlots, availableMapKinds, totalTextureCount, totalTextureOriginal, totalTextureCompressed); } } ImGui.EndGroup(); var filterMin = ImGui.GetItemRectMin(); var filterMax = ImGui.GetItemRectMax(); var filterHeight = filterMax.Y - filterMin.Y; var filterTopLocal = filterMin - windowPos; var maxFilterResize = Math.Min(MaxTextureFilterPaneWidth, Math.Max(minFilterWidth, availableSize.X - minCenterWidth - detailMinForLayout - totalSplitterWidth - totalSpacing)); DrawVerticalResizeHandle("##textureFilterSplitter", filterTopLocal.Y, filterHeight, ref _textureFilterPaneWidth, minFilterWidth, maxFilterResize, out _); TextureRow? selectedRow; ImGui.BeginGroup(); using (var tableChild = ImRaii.Child("textureTableArea", new Vector2(centerWidth, 0), false)) { selectedRow = DrawTextureTable(rows, totalOriginal, totalCompressed, hasAnyTextureRows); } ImGui.EndGroup(); var tableMin = ImGui.GetItemRectMin(); var tableMax = ImGui.GetItemRectMax(); var tableHeight = tableMax.Y - tableMin.Y; var tableTopLocal = tableMin - windowPos; var maxDetailResize = Math.Min(MaxTextureDetailPaneWidth, Math.Max(minDetailWidth, availableSize.X - _textureFilterPaneWidth - minCenterWidth - totalSplitterWidth - totalSpacing)); var detailToggle = DrawVerticalResizeHandle( "##textureDetailSplitter", tableTopLocal.Y, tableHeight, ref _textureDetailPaneWidth, minDetailWidth, maxDetailResize, out var detailDragging, invert: true, showToggle: true, isCollapsed: _textureDetailCollapsed); if (detailToggle) { _textureDetailCollapsed = !_textureDetailCollapsed; } if (_textureDetailCollapsed && detailDragging) { _textureDetailCollapsed = false; } if (!_textureDetailCollapsed) { ImGui.BeginGroup(); using (var detailChild = ImRaii.Child("textureDetailPane", new Vector2(detailWidth, 0), true)) { DrawTextureDetail(selectedRow); } ImGui.EndGroup(); } } private void DrawTextureFilters( IReadOnlyList categories, IReadOnlyList slots, IReadOnlyList mapKinds, int totalTextureCount, long totalTextureOriginal, long totalTextureCompressed) { var scale = ImGuiHelpers.GlobalScale; var accent = UIColors.Get("LightlessBlue"); var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.14f); var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.35f); var infoColor = ImGuiColors.DalamudGrey; 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))) { var lineHeight = ImGui.GetTextLineHeightWithSpacing(); var summaryHeight = MathF.Max(lineHeight * 2.4f, ImGui.GetFrameHeightWithSpacing() * 2.2f); using (var totals = ImRaii.Child("textureTotalsSummary", new Vector2(-1f, summaryHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) { if (totals) { using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(8f * scale, 4f * scale))) { if (ImGui.BeginTable("textureTotalsSummaryTable", 3, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.PadOuterX | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.NoHostExtendX)) { ImGui.TableNextRow(); DrawSummaryCell(FontAwesomeIcon.Images, accent, $"{totalTextureCount:N0}", totalTextureCount == 1 ? "tex file" : "tex files", infoColor, scale); DrawSummaryCell(FontAwesomeIcon.FileArchive, ImGuiColors.DalamudGrey, UiSharedService.ByteToString(totalTextureOriginal), "Actual size", infoColor, scale); DrawSummaryCell(FontAwesomeIcon.CompressArrowsAlt, UIColors.Get("LightlessYellow2"), UiSharedService.ByteToString(totalTextureCompressed), "Compressed size", infoColor, scale); ImGui.EndTable(); } } } } } ImGuiHelpers.ScaledDummy(1); ImGui.TextUnformatted("Filters"); ImGui.Separator(); ImGui.SetNextItemWidth(-1); ImGui.InputTextWithHint("##textureSearch", "Search hash, path, or file name...", ref _textureSearch, 256); ImGuiHelpers.ScaledDummy(6); DrawEnumFilterCombo("Category", "All Categories", ref _textureCategoryFilter, categories); DrawStringFilterCombo("Slot", ref _textureSlotFilter, slots, "All"); DrawEnumFilterCombo("Map Type", "All Map Types", ref _textureMapFilter, mapKinds); if (_textureTargetFilter.HasValue && !_textureCompressionService.IsTargetSelectable(_textureTargetFilter.Value)) { _textureTargetFilter = null; } DrawEnumFilterCombo("Compression", "Any Compression", ref _textureTargetFilter, _textureCompressionService.SelectableTargets); ImGuiHelpers.ScaledDummy(8); if (_uiSharedService.IconTextButton(FontAwesomeIcon.Undo, "Reset filters")) { ResetTextureFilters(); } ImGuiHelpers.ScaledDummy(6); ImGui.Separator(); ImGuiHelpers.ScaledDummy(4); UiSharedService.ColorText("Texture row colors", UIColors.Get("LightlessPurple")); DrawTextureRowLegendItem("Selected", UIColors.Get("LightlessYellow"), "This row is selected in the texture table."); DrawTextureRowLegendItem("Already compressed", UIColors.Get("LightlessGreenDefault"), "Texture is already stored in a compressed format."); DrawTextureRowLegendItem("Missing analysis data", UIColors.Get("DimRed"), "File size data has not been computed yet."); } private static void DrawTextureRowLegendItem(string label, Vector4 color, string description) { var scale = ImGuiHelpers.GlobalScale; var swatchSize = new Vector2(12f * scale, 12f * scale); ImGui.ColorButton($"##textureRowLegend{label}", color, ImGuiColorEditFlags.NoTooltip | ImGuiColorEditFlags.NoDragDrop, swatchSize); ImGui.SameLine(0f, 6f * scale); var wrapPos = ImGui.GetCursorPosX() + ImGui.GetContentRegionAvail().X; UiSharedService.TextWrapped($"{label}: {description}", wrapPos); } private static void DrawEnumFilterCombo( string label, string allLabel, ref T? currentSelection, IEnumerable options) where T : struct, Enum { var displayLabel = currentSelection?.ToString() ?? allLabel; if (!ImGui.BeginCombo(label, displayLabel)) { return; } bool noSelection = !currentSelection.HasValue; if (ImGui.Selectable(allLabel, noSelection)) { currentSelection = null; } if (noSelection) { ImGui.SetItemDefaultFocus(); } var comparer = EqualityComparer.Default; foreach (var option in options) { bool selected = currentSelection.HasValue && comparer.Equals(currentSelection.Value, option); if (ImGui.Selectable(option.ToString(), selected)) { currentSelection = option; } if (selected) { ImGui.SetItemDefaultFocus(); } } ImGui.EndCombo(); } private static void DrawStringFilterCombo( string label, ref string currentSelection, IEnumerable options, string allLabel) { var displayLabel = string.IsNullOrEmpty(currentSelection) || string.Equals(currentSelection, allLabel, StringComparison.Ordinal) ? allLabel : currentSelection; if (!ImGui.BeginCombo(label, displayLabel)) { return; } bool allSelected = string.Equals(currentSelection, allLabel, StringComparison.Ordinal); if (ImGui.Selectable(allLabel, allSelected)) { currentSelection = allLabel; } if (allSelected) { ImGui.SetItemDefaultFocus(); } foreach (var option in options) { bool selected = string.Equals(currentSelection, option, StringComparison.OrdinalIgnoreCase); if (ImGui.Selectable(option, selected)) { currentSelection = option; } if (selected) { ImGui.SetItemDefaultFocus(); } } ImGui.EndCombo(); } private void DrawSummaryCell( FontAwesomeIcon icon, Vector4 iconColor, string mainText, string subText, Vector4 subColor, float scale, string? extraText = null, Vector4? extraColor = null, string? tooltip = null) { ImGui.TableNextColumn(); var spacing = new Vector2(6f * scale, 2f * scale); using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, spacing)) { ImGui.BeginGroup(); _uiSharedService.IconText(icon, iconColor); ImGui.SameLine(0f, 4f * scale); using (ImRaii.PushColor(ImGuiCol.Text, iconColor)) { ImGui.TextUnformatted(mainText); } using (ImRaii.PushColor(ImGuiCol.Text, subColor)) { ImGui.TextUnformatted(subText); } if (!string.IsNullOrWhiteSpace(extraText)) { ImGui.SameLine(0f, 4f * scale); using (ImRaii.PushColor(ImGuiCol.Text, extraColor ?? subColor)) { ImGui.TextUnformatted(extraText); } } ImGui.EndGroup(); } if (!string.IsNullOrWhiteSpace(tooltip) && ImGui.IsItemHovered()) { ImGui.SetTooltip(tooltip); } } 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) { return; } var scale = ImGuiHelpers.GlobalScale; ImGuiHelpers.ScaledDummy(8); var accent = UIColors.Get("LightlessBlue"); var sectionAvail = ImGui.GetContentRegionAvail().Y; IGrouping? activeGroup = null; 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(12f * scale, 6f * scale))) using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(12f * scale, 4f * scale))) using (var child = ImRaii.Child("otherFileTypes", new Vector2(-1f, sectionAvail - (SelectedFilePanelLogicalHeight * scale) - 12f * scale), true)) { if (child) { UiSharedService.ColorText("Other file types", UIColors.Get("LightlessPurple")); ImGuiHelpers.ScaledDummy(4); using var tabBar = ImRaii.TabBar("otherFileTabs", ImGuiTabBarFlags.NoCloseWithMiddleMouseButton); foreach (var fileGroup in otherFileGroups) { string tabLabel = $"{fileGroup.Key} [{fileGroup.Count()}]"; var requiresCompute = fileGroup.Any(k => !k.IsComputed); using var tabCol = ImRaii.PushColor(ImGuiCol.Tab, UiSharedService.Color(UIColors.Get("LightlessYellow")), requiresCompute); if (requiresCompute) { tabLabel += " (!)"; } ImRaii.IEndObject tabItem; using (var textCol = ImRaii.PushColor(ImGuiCol.Text, UiSharedService.Color(new Vector4(0, 0, 0, 1)), requiresCompute && !string.Equals(_selectedFileTypeTab, fileGroup.Key, StringComparison.Ordinal))) { tabItem = ImRaii.TabItem(tabLabel + "###other_" + fileGroup.Key); } if (!tabItem) { tabItem.Dispose(); continue; } activeGroup = fileGroup; if (!string.Equals(_selectedFileTypeTab, fileGroup.Key, StringComparison.Ordinal)) { _selectedFileTypeTab = fileGroup.Key; _selectedHash = string.Empty; } var originalTotal = fileGroup.Sum(c => c.OriginalSize); var compressedTotal = fileGroup.Sum(c => c.CompressedSize); 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(420f * 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($"otherFileSummary##{fileGroup.Key}", 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"); 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("otherFileSummaryTable", 3, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.PadOuterX | ImGuiTableFlags.NoHostExtendX, new Vector2(-1f, -1f)); if (summaryTable) { ImGui.TableNextRow(); DrawSummaryCell(FontAwesomeIcon.LayerGroup, countColor, fileGroup.Count().ToString("N0", CultureInfo.InvariantCulture), $"{fileGroup.Key} 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); } } } } using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(12f * scale, 6f * 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(fileGroup); } tabItem.Dispose(); } } } if (activeGroup == null && otherFileGroups.Count > 0) { activeGroup = otherFileGroups[0]; } 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; var selectionInGroup = hasGroup && !string.IsNullOrEmpty(_selectedHash) && fileGroup!.Any(entry => string.Equals(entry.Hash, _selectedHash, StringComparison.Ordinal)); var scale = ImGuiHelpers.GlobalScale; var accent = UIColors.Get("LightlessBlue"); var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.12f); var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.3f); 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(accentBg))) using (ImRaii.PushColor(ImGuiCol.Border, UiSharedService.Color(accentBorder))) using (var child = ImRaii.Child("selectedFileDetails", new Vector2(-1f, SelectedFilePanelLogicalHeight * scale), true)) { if (!child) { return; } if (!selectionInGroup || _cachedAnalysis == null || !_cachedAnalysis.TryGetValue(_selectedObjectTab, out var objectEntries) || !objectEntries.TryGetValue(_selectedHash, out var item)) { UiSharedService.ColorText("Select a file row to view details.", ImGuiColors.DalamudGrey); return; } UiSharedService.ColorText("Selected file:", UIColors.Get("LightlessBlue")); ImGui.SameLine(); UiSharedService.ColorText(_selectedHash, UIColors.Get("LightlessYellow")); ImGuiHelpers.ScaledDummy(2); UiSharedService.ColorText("Local file path:", UIColors.Get("LightlessBlue")); ImGui.SameLine(); UiSharedService.TextWrapped(item.FilePaths[0]); if (item.FilePaths.Count > 1) { ImGui.SameLine(); ImGui.TextUnformatted($"(and {item.FilePaths.Count - 1} more)"); ImGui.SameLine(); _uiSharedService.IconText(FontAwesomeIcon.InfoCircle); UiSharedService.AttachToolTip(string.Join(Environment.NewLine, item.FilePaths.Skip(1))); } UiSharedService.ColorText("Used by game path:", UIColors.Get("LightlessBlue")); ImGui.SameLine(); UiSharedService.TextWrapped(item.GamePaths[0]); if (item.GamePaths.Count > 1) { ImGui.SameLine(); ImGui.TextUnformatted($"(and {item.GamePaths.Count - 1} more)"); ImGui.SameLine(); _uiSharedService.IconText(FontAwesomeIcon.InfoCircle); UiSharedService.AttachToolTip(string.Join(Environment.NewLine, item.GamePaths.Skip(1))); } } } private TextureRow? DrawTextureTable(IReadOnlyList rows, long totalOriginal, long totalCompressed, bool hasAnyTextureRows) { var scale = ImGuiHelpers.GlobalScale; if (rows.Count > 0) { var accent = UIColors.Get("LightlessBlue"); var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.14f); var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.35f); var originalColor = ImGuiColors.DalamudGrey; var compressedColor = UIColors.Get("LightlessYellow2"); var infoColor = ImGuiColors.DalamudGrey; var countLabel = rows.Count == 1 ? "Matching texture" : "Matching textures"; var diff = totalOriginal - totalCompressed; var diffMagnitude = Math.Abs(diff); var diffColor = diff > 0 ? UIColors.Get("LightlessGreen") : diff < 0 ? UIColors.Get("DimRed") : ImGuiColors.DalamudGrey; var diffLabel = diff > 0 ? "Saved" : diff < 0 ? "Overhead" : "Lossless"; var diffPercent = diffMagnitude > 0 && totalOriginal > 0 ? ((diffMagnitude * 100d) / totalOriginal).ToString("0", CultureInfo.InvariantCulture) + "%" : null; 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))) { var lineHeight = ImGui.GetTextLineHeightWithSpacing(); var summaryHeight = MathF.Max(lineHeight * 2.4f, ImGui.GetFrameHeightWithSpacing() * 2.2f); using (var summary = ImRaii.Child("textureCompressionSummary", new Vector2(-1f, summaryHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) { if (summary) { using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(8f * scale, 4f * scale))) { if (ImGui.BeginTable("textureCompressionSummaryTable", 4, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.PadOuterX | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.NoHostExtendX)) { ImGui.TableNextRow(); DrawSummaryStat(FontAwesomeIcon.Images, accent, $"{rows.Count:N0}", countLabel, infoColor); DrawSummaryStat(FontAwesomeIcon.FileArchive, originalColor, UiSharedService.ByteToString(totalOriginal), "Original total", infoColor); DrawSummaryStat(FontAwesomeIcon.CompressArrowsAlt, compressedColor, UiSharedService.ByteToString(totalCompressed), "Compressed total", infoColor); DrawSummaryStat( FontAwesomeIcon.ChartLine, diffColor, diffMagnitude > 0 ? UiSharedService.ByteToString(diffMagnitude) : "No change", diffLabel, diffColor, diffPercent, diffColor); ImGui.EndTable(); } } } } } } else { if (hasAnyTextureRows) { UiSharedService.TextWrapped("No textures match the current filters. Try clearing filters or refreshing the analysis session."); } else { UiSharedService.ColorText("No textures recorded for this object.", ImGuiColors.DalamudGrey); } } UiSharedService.TextWrapped("Mark textures using the checkbox to add them to the compression queue. You can adjust the target format for each texture before starting the batch compression."); bool conversionRunning = _conversionTask != null && !_conversionTask.IsCompleted; var activeSelectionCount = _textureRows.Count(row => _selectedTextureKeys.Contains(row.Key) && !row.IsAlreadyCompressed); bool hasSelection = activeSelectionCount > 0; using (ImRaii.Disabled(conversionRunning || !hasSelection)) { var label = hasSelection ? $"Compress {activeSelectionCount} selected" : "Compress selected"; if (_uiSharedService.IconTextButton(FontAwesomeIcon.CompressArrowsAlt, label, 220f * scale)) { StartTextureConversion(); } } ImGui.SameLine(); using (ImRaii.Disabled(_selectedTextureKeys.Count == 0 && _textureSelections.Count == 0)) { if (_uiSharedService.IconTextButton(FontAwesomeIcon.Broom, "Clear marks", 160f * scale)) { _selectedTextureKeys.Clear(); _textureSelections.Clear(); } } ImGui.SameLine(); if (_uiSharedService.IconTextButton(FontAwesomeIcon.Sync, "Refresh", 130f * scale)) { 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, ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY | ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.BordersOuter | ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.Sortable, new Vector2(-1, 0))) { if (table) { ImGui.TableSetupColumn("##select", ImGuiTableColumnFlags.WidthFixed | ImGuiTableColumnFlags.NoSort, 32f * scale); ImGui.TableSetupColumn("Texture", ImGuiTableColumnFlags.WidthFixed | ImGuiTableColumnFlags.NoSort, 310f * scale); ImGui.TableSetupColumn("Slot", ImGuiTableColumnFlags.NoSort); ImGui.TableSetupColumn("Map", ImGuiTableColumnFlags.NoSort); ImGui.TableSetupColumn("Format", ImGuiTableColumnFlags.NoSort); ImGui.TableSetupColumn("Recommended", ImGuiTableColumnFlags.NoSort); ImGui.TableSetupColumn("Target", ImGuiTableColumnFlags.WidthFixed | ImGuiTableColumnFlags.NoSort, 140f * scale); ImGui.TableSetupColumn("Original", ImGuiTableColumnFlags.PreferSortDescending); ImGui.TableSetupColumn("Compressed", ImGuiTableColumnFlags.PreferSortDescending); ImGui.TableSetupScrollFreeze(0, 1); 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]; if (spec.ColumnIndex is 7 or 8) { 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; } var index = 0; foreach (var row in orderedRows) { DrawTextureRow(row, targets, index); index++; } } } return rows.FirstOrDefault(r => r.Key == _selectedTextureKey); void DrawSummaryStat(FontAwesomeIcon icon, Vector4 iconColor, string mainText, string subText, Vector4 subColor, string? extraText = null, Vector4? extraColor = null) { 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)) { ImGui.TextUnformatted(mainText); } using (ImRaii.PushColor(ImGuiCol.Text, subColor)) { ImGui.TextUnformatted(subText); } if (!string.IsNullOrWhiteSpace(extraText)) { ImGui.SameLine(0f, 6f * scale); using (ImRaii.PushColor(ImGuiCol.Text, extraColor ?? iconColor)) { ImGui.TextUnformatted(extraText); } } } } } 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) { return; } var selectedRows = _textureRows .Where(row => _selectedTextureKeys.Contains(row.Key) && !row.IsAlreadyCompressed) .ToList(); if (selectedRows.Count == 0) { return; } var requests = selectedRows.Select(row => { var desiredTarget = _textureSelections.TryGetValue(row.Key, out var selection) ? selection : row.SuggestedTarget ?? row.CurrentTarget ?? _textureCompressionService.DefaultTarget; var normalizedTarget = _textureCompressionService.NormalizeTarget(desiredTarget); _textureSelections[row.Key] = normalizedTarget; return new TextureCompressionRequest(row.PrimaryFilePath, row.DuplicateFilePaths, normalizedTarget); }).ToList(); _conversionCancellationTokenSource = _conversionCancellationTokenSource.CancelRecreate(); _conversionTotalJobs = requests.Count; _lastConversionProgress = null; _conversionCurrentFileName = string.Empty; _conversionCurrentFileProgress = 0; _conversionFailed = false; var conversionToken = _conversionCancellationTokenSource.Token; _conversionTask = _processingQueue.Enqueue( queueToken => RunTextureConversionAsync(requests, queueToken), conversionToken); _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 { await _textureCompressionService.ConvertTexturesAsync(requests, _conversionProgress, token).ConfigureAwait(false); if (!token.IsCancellationRequested) { var affectedPaths = requests .SelectMany(static request => { IEnumerable paths = request.DuplicateFilePaths; return new[] { request.PrimaryFilePath }.Concat(paths); }) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (affectedPaths.Count > 0) { try { await _characterAnalyzer.UpdateFileEntriesAsync(affectedPaths, token, force: true).ConfigureAwait(false); _hasUpdate = true; } catch (OperationCanceledException) { throw; } catch (Exception updateEx) { _logger.LogWarning(updateEx, "Failed to refresh compressed size data after texture conversion."); } } } } catch (OperationCanceledException) { _logger.LogInformation("Texture compression was cancelled."); } catch (Exception ex) { _conversionFailed = true; _logger.LogError(ex, "Texture compression failed."); } finally { _selectedTextureKeys.Clear(); _textureSelections.Clear(); InvalidateTextureRows(); } } 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, float height, ref float leftWidth, float minWidth, float maxWidth, out bool isDragging, bool invert = false, bool showToggle = false, bool isCollapsed = false, float? splitterWidthOverride = null) { var scale = ImGuiHelpers.GlobalScale; var baseWidth = splitterWidthOverride ?? (showToggle ? (isCollapsed ? TextureDetailSplitterCollapsedWidth : TextureDetailSplitterWidth) : TextureFilterSplitterWidth); var splitterWidth = baseWidth * scale; ImGui.SameLine(); var cursor = ImGui.GetCursorPos(); var contentMin = ImGui.GetWindowContentRegionMin(); var contentMax = ImGui.GetWindowContentRegionMax(); var clampedTop = MathF.Max(topY, contentMin.Y); var clampedBottom = MathF.Min(topY + height, contentMax.Y); var clampedHeight = MathF.Max(0f, clampedBottom - clampedTop); var splitterRounding = ImGui.GetStyle().FrameRounding; ImGui.SetCursorPos(new Vector2(cursor.X, clampedTop)); if (clampedHeight <= 0f) { isDragging = false; ImGui.SetCursorPos(new Vector2(cursor.X + splitterWidth + ImGui.GetStyle().ItemSpacing.X, cursor.Y)); return false; } ImGui.InvisibleButton(id, new Vector2(splitterWidth, clampedHeight)); var drawList = ImGui.GetWindowDrawList(); var rectMin = ImGui.GetItemRectMin(); var rectMax = ImGui.GetItemRectMax(); var windowPos = ImGui.GetWindowPos(); var clipMin = windowPos + contentMin; var clipMax = windowPos + contentMax; drawList.PushClipRect(clipMin, clipMax, true); var clipInset = 1f * scale; var drawMin = new Vector2( MathF.Max(rectMin.X, clipMin.X), MathF.Max(rectMin.Y, clipMin.Y)); var drawMax = new Vector2( MathF.Min(rectMax.X, clipMax.X - clipInset), MathF.Min(rectMax.Y, clipMax.Y)); 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; drawList.AddRectFilled(drawMin, drawMax, UiSharedService.Color(handleColor), splitterRounding); drawList.AddRect(drawMin, drawMax, UiSharedService.Color(new Vector4(1f, 1f, 1f, 0.12f)), splitterRounding); bool toggleHovered = false; bool toggleClicked = false; if (showToggle) { var icon = !isCollapsed ? FontAwesomeIcon.ChevronRight : FontAwesomeIcon.ChevronLeft; Vector2 iconSize; using (_uiSharedService.IconFont.Push()) { iconSize = ImGui.CalcTextSize(icon.ToIconString()); } var toggleHeight = MathF.Min(clampedHeight, 64f * scale); var toggleMin = new Vector2( drawMin.X, drawMin.Y + (drawMax.Y - drawMin.Y - toggleHeight) / 2f); var toggleMax = new Vector2( drawMax.X, toggleMin.Y + toggleHeight); var toggleColorBase = UIColors.Get("LightlessPurple"); toggleHovered = ImGui.IsMouseHoveringRect(toggleMin, toggleMax); var toggleBg = toggleHovered ? new Vector4(toggleColorBase.X, toggleColorBase.Y, toggleColorBase.Z, 0.65f) : new Vector4(toggleColorBase.X, toggleColorBase.Y, toggleColorBase.Z, 0.35f); if (toggleHovered) { UiSharedService.AttachToolTip(isCollapsed ? "Show texture details." : "Hide texture details."); } drawList.AddRectFilled(toggleMin, toggleMax, UiSharedService.Color(toggleBg), splitterRounding); drawList.AddRect(toggleMin, toggleMax, UiSharedService.Color(toggleColorBase), splitterRounding); var iconPos = new Vector2( drawMin.X + (drawMax.X - drawMin.X - iconSize.X) / 2f, drawMin.Y + (drawMax.Y - drawMin.Y - iconSize.Y) / 2f); using (_uiSharedService.IconFont.Push()) { drawList.AddText(iconPos, ImGui.GetColorU32(ImGuiCol.Text), icon.ToIconString()); } if (toggleHovered && ImGui.IsMouseReleased(ImGuiMouseButton.Left) && !ImGui.IsMouseDragging(ImGuiMouseButton.Left)) { toggleClicked = true; } } if (isDragging && !toggleHovered) { var delta = ImGui.GetIO().MouseDelta.X / scale; leftWidth += invert ? -delta : delta; leftWidth = Math.Clamp(leftWidth, minWidth, maxWidth); } drawList.PopClipRect(); ImGui.SetCursorPos(new Vector2(cursor.X + splitterWidth + ImGui.GetStyle().ItemSpacing.X, cursor.Y)); 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; if (!_texturePreviews.TryGetValue(key, out var state)) { state = new TexturePreviewState(); _texturePreviews[key] = state; } state.LastAccessUtc = DateTime.UtcNow; if (state.Texture != null) { return (state.Texture, false, state.ErrorMessage); } if (state.LoadTask == null) { state.LoadTask = Task.Run(async () => { try { var texture = await BuildPreviewAsync(row, CancellationToken.None).ConfigureAwait(false); state.Texture = texture; state.ErrorMessage = texture == null ? "Preview unavailable." : null; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to load preview for {File}", row.PrimaryFilePath); state.ErrorMessage = "Failed to load preview."; } }); } if (state.LoadTask.IsCompleted) { state.LoadTask = null; return (state.Texture, false, state.ErrorMessage); } return (null, true, state.ErrorMessage); } private async Task BuildPreviewAsync(TextureRow row, CancellationToken token) { const int PreviewMaxDimension = 1024; token.ThrowIfCancellationRequested(); if (!File.Exists(row.PrimaryFilePath)) { return null; } try { using var scratch = TexFileHelper.Load(row.PrimaryFilePath); using var rgbaScratch = scratch.GetRGBA(out var rgbaInfo).ThrowIfError(rgbaInfo); var meta = rgbaInfo.Meta; var width = meta.Width; var height = meta.Height; var bytesPerPixel = meta.Format.BitsPerPixel() / 8; var requiredLength = width * height * bytesPerPixel; token.ThrowIfCancellationRequested(); var rgbaPixels = rgbaScratch.Pixels[..requiredLength].ToArray(); using var image = ImageSharpImage.LoadPixelData(rgbaPixels, width, height); if (Math.Max(width, height) > PreviewMaxDimension) { var dominant = Math.Max(width, height); var scale = PreviewMaxDimension / (float)dominant; var targetWidth = Math.Max(1, (int)MathF.Round(width * scale)); var targetHeight = Math.Max(1, (int)MathF.Round(height * scale)); image.Mutate(ctx => ctx.Resize(targetWidth, targetHeight, KnownResamplers.Lanczos3)); } using var ms = new MemoryStream(); await image.SaveAsPngAsync(ms, cancellationToken: token).ConfigureAwait(false); return _uiSharedService.LoadImage(ms.ToArray()); } catch (OperationCanceledException) { return null; } catch (Exception ex) { _logger.LogDebug(ex, "Preview generation failed for {File}", row.PrimaryFilePath); return null; } } private void ResetPreview(string key) { 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)) { return cached; } var info = TryReadTextureResolution(row.PrimaryFilePath, out var resolved) ? resolved : (TextureResolutionInfo?)null; _textureResolutionCache[row.Key] = info; return info; } private static bool TryReadTextureResolution(string path, out TextureResolutionInfo info) { info = default; try { Span buffer = stackalloc byte[16]; using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); var read = stream.Read(buffer); if (read < buffer.Length) { return false; } var width = BinaryPrimitives.ReadUInt16LittleEndian(buffer[8..10]); var height = BinaryPrimitives.ReadUInt16LittleEndian(buffer[10..12]); var depth = BinaryPrimitives.ReadUInt16LittleEndian(buffer[12..14]); var mipLevels = BinaryPrimitives.ReadUInt16LittleEndian(buffer[14..16]); if (width == 0 || height == 0) { return false; } if (depth == 0) { depth = 1; } if (mipLevels == 0) { mipLevels = 1; } info = new TextureResolutionInfo(width, height, depth, mipLevels); return true; } catch { return false; } } private static string FormatTextureResolution(TextureResolutionInfo info) => info.Depth > 1 ? $"{info.Width} x {info.Height} x {info.Depth}" : $"{info.Width} x {info.Height}"; private void DrawTextureRow(TextureRow row, IReadOnlyList targets, int index) { var key = row.Key; bool isSelected = string.Equals(_selectedTextureKey, key, StringComparison.Ordinal); ImGui.TableNextRow(ImGuiTableRowFlags.None, 0); ApplyTextureRowBackground(row, isSelected); bool canCompress = !row.IsAlreadyCompressed; if (!canCompress) { _selectedTextureKeys.Remove(key); _textureSelections.Remove(key); } ImGui.TableNextColumn(); if (canCompress) { bool marked = _selectedTextureKeys.Contains(key); if (UiSharedService.CheckboxWithBorder($"##select{index}", ref marked, UIColors.Get("LightlessPurple"), 1.5f)) { if (marked) { _selectedTextureKeys.Add(key); } else { _selectedTextureKeys.Remove(key); } } UiSharedService.AttachToolTip("Mark texture for batch compression."); } else { _uiSharedService.IconText(FontAwesomeIcon.Check, ImGuiColors.DalamudWhite); UiSharedService.AttachToolTip("Already stored in a compressed format; additional compression is disabled."); } var nameHovered = DrawSelectableColumn(isSelected, () => { var selectableLabel = $"{row.DisplayName}##texName{index}"; if (ImGui.Selectable(selectableLabel, isSelected)) { _selectedTextureKey = isSelected ? string.Empty : key; } return null; }); _ = DrawSelectableColumn(isSelected, () => { ImGui.TextUnformatted(row.Slot); return null; }); _ = DrawSelectableColumn(isSelected, () => { ImGui.TextUnformatted(row.MapKind.ToString()); return null; }); _ = DrawSelectableColumn(isSelected, () => { Action? tooltipAction = null; ImGui.TextUnformatted(row.Format); if (!row.IsAlreadyCompressed) { ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale); var iconColor = isSelected ? SelectedTextureRowTextColor : UIColors.Get("LightlessYellow"); _uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, iconColor); tooltipAction = () => UiSharedService.AttachToolTip("Run compression to reduce file size."); } return tooltipAction; }); _ = DrawSelectableColumn(isSelected, () => { if (row.SuggestedTarget.HasValue) { ImGui.TextUnformatted(row.SuggestedTarget.Value.ToString()); if (!string.IsNullOrEmpty(row.SuggestionReason)) { var reason = row.SuggestionReason; return () => UiSharedService.AttachToolTip(reason); } } else { ImGui.TextUnformatted("-"); } return null; }); ImGui.TableNextColumn(); if (canCompress) { var desiredTarget = _textureSelections.TryGetValue(key, out var storedTarget) ? storedTarget : row.SuggestedTarget ?? row.CurrentTarget ?? _textureCompressionService.DefaultTarget; var currentSelection = _textureCompressionService.NormalizeTarget(desiredTarget); if (!_textureSelections.TryGetValue(key, out _) || storedTarget != currentSelection) { _textureSelections[key] = currentSelection; } var comboLabel = currentSelection.ToString(); ImGui.SetNextItemWidth(-1); if (ImGui.BeginCombo($"##target{index}", comboLabel)) { foreach (var target in targets) { bool targetSelected = currentSelection == target; if (ImGui.Selectable(target.ToString(), targetSelected)) { _textureSelections[key] = target; currentSelection = target; } if (TextureMetadataHelper.TryGetRecommendationInfo(target, out var targetInfo)) { UiSharedService.AttachToolTip($"{targetInfo.Title}{UiSharedService.TooltipSeparator}{targetInfo.Description}"); } if (targetSelected) { ImGui.SetItemDefaultFocus(); } } ImGui.EndCombo(); } } else { var label = row.CurrentTarget?.ToString() ?? row.Format; ImGui.TextUnformatted(label); UiSharedService.AttachToolTip("This texture is already compressed and cannot be processed again."); } _ = DrawSelectableColumn(isSelected, () => { ImGui.TextUnformatted(UiSharedService.ByteToString(row.OriginalSize)); return null; }); _ = DrawSelectableColumn(isSelected, () => { ImGui.TextUnformatted(UiSharedService.ByteToString(row.CompressedSize)); return null; }); DrawTextureRowHoverTooltip(row, nameHovered); } private static bool DrawSelectableColumn(bool isSelected, Func draw) { ImGui.TableNextColumn(); if (isSelected) { ImGui.PushStyleColor(ImGuiCol.Text, SelectedTextureRowTextColor); } var after = draw(); var hovered = ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled | ImGuiHoveredFlags.AllowWhenBlockedByActiveItem); if (isSelected) { ImGui.PopStyleColor(); } 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) { if (isSelected) { var highlight = UiSharedService.Color(UIColors.Get("LightlessYellow")); ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg0, highlight); ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg1, highlight); } else if (row.IsAlreadyCompressed) { var compressedColor = UiSharedService.Color(UIColors.Get("LightlessGreenDefault")); ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg0, compressedColor); ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg1, compressedColor); } else if (!row.IsComputed) { var warning = UiSharedService.Color(UIColors.Get("DimRed")); ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg0, warning); ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg1, warning); } } private void DrawTextureDetail(TextureRow? row) { var scale = ImGuiHelpers.GlobalScale; UiSharedService.ColorText("Texture Details", UIColors.Get("LightlessPurple")); if (row != null) { ImGui.SameLine(); ImGui.TextUnformatted(row.DisplayName); UiSharedService.AttachToolTip("Source file: " + row.PrimaryFilePath); } ImGui.Separator(); if (row == null) { UiSharedService.ColorText("Select a texture to view details.", ImGuiColors.DalamudGrey); return; } var (previewTexture, previewLoading, previewError) = GetTexturePreview(row); var previewSize = new Vector2(_texturePreviewSize * 0.85f, _texturePreviewSize * 0.85f) * scale; if (previewTexture != null) { ImGui.Image(previewTexture.Handle, previewSize); if (_uiSharedService.IconTextButton(FontAwesomeIcon.SyncAlt, "Refresh preview", 180f * ImGuiHelpers.GlobalScale)) { ResetPreview(row.Key); } } else { using (ImRaii.Child("previewPlaceholder", previewSize, true)) { UiSharedService.TextWrapped(previewLoading ? "Generating preview..." : previewError ?? "Preview unavailable."); } if (!previewLoading && _uiSharedService.IconTextButton(FontAwesomeIcon.SyncAlt, "Retry preview", 180f * ImGuiHelpers.GlobalScale)) { ResetPreview(row.Key); } } var desiredDetailTarget = _textureSelections.TryGetValue(row.Key, out var userTarget) ? userTarget : row.SuggestedTarget ?? row.CurrentTarget ?? _textureCompressionService.DefaultTarget; var selectedTarget = _textureCompressionService.NormalizeTarget(desiredDetailTarget); if (!_textureSelections.TryGetValue(row.Key, out _) || userTarget != selectedTarget) { _textureSelections[row.Key] = selectedTarget; } var hasSelectedInfo = TextureMetadataHelper.TryGetRecommendationInfo(selectedTarget, out var selectedInfo); using (ImRaii.Child("textureDetailInfo", new Vector2(-1, 0), true, ImGuiWindowFlags.AlwaysVerticalScrollbar)) { using var detailSpacing = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(6f * scale, 2f * scale)); DrawMetaOverview(); ImGuiHelpers.ScaledDummy(4); DrawSizeSummary(); ImGuiHelpers.ScaledDummy(4); DrawCompressionInsights(); ImGuiHelpers.ScaledDummy(6); DrawExpandableList("Duplicate Files", row.DuplicateFilePaths, "No duplicate files detected."); DrawExpandableList("Game Paths", row.GamePaths, "No game paths recorded."); } void DrawMetaOverview() { var labelColor = ImGuiColors.DalamudGrey; using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(5f * scale, 2f * scale))) { var metaFlags = ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.PadOuterX; if (ImGui.BeginTable("textureMetaOverview", 2, metaFlags)) { MetaRow(FontAwesomeIcon.Cube, "Object", row.ObjectKind.ToString()); MetaRow(FontAwesomeIcon.Tag, "Slot", row.Slot); MetaRow(FontAwesomeIcon.LayerGroup, "Map Type", row.MapKind.ToString()); MetaRow(FontAwesomeIcon.Fingerprint, "Hash", row.Hash, UIColors.Get("LightlessBlue")); MetaRow(FontAwesomeIcon.InfoCircle, "Current Format", row.Format); var resolution = GetTextureResolution(row); var resolutionLabel = resolution.HasValue ? FormatTextureResolution(resolution.Value) : "Unknown"; MetaRow(FontAwesomeIcon.Images, "Resolution", resolutionLabel); var selectedLabel = hasSelectedInfo ? selectedInfo!.Title : selectedTarget.ToString(); var selectionColor = hasSelectedInfo ? UIColors.Get("LightlessYellow") : UIColors.Get("LightlessGreen"); MetaRow(FontAwesomeIcon.Bullseye, "Selected Target", selectedLabel, selectionColor); ImGui.EndTable(); } } 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 DrawSizeSummary() { var savedBytes = row.OriginalSize - row.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 = row.OriginalSize > 0 && savedMagnitude > 0 ? $"{savedMagnitude * 100d / row.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("textureSizeSummary", 3, statFlags)) { ImGui.TableNextRow(); StatCell(FontAwesomeIcon.Database, ImGuiColors.DalamudGrey, UiSharedService.ByteToString(row.OriginalSize), "Original"); StatCell(FontAwesomeIcon.CompressArrowsAlt, UIColors.Get("LightlessYellow2"), UiSharedService.ByteToString(row.CompressedSize), "Compressed"); StatCell(FontAwesomeIcon.ChartLine, savedColor, savedMagnitude > 0 ? UiSharedService.ByteToString(savedMagnitude) : "No change", savedLabel, savedPercent, savedColor); ImGui.EndTable(); } } 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 DrawCompressionInsights() { var matchesRecommendation = row.SuggestedTarget.HasValue && row.SuggestedTarget.Value == selectedTarget; var columnCount = row.SuggestedTarget.HasValue ? 2 : 1; using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) { var cardFlags = ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.PadOuterX; if (ImGui.BeginTable("textureCompressionCards", columnCount, cardFlags)) { ImGui.TableNextRow(); var selectedTitleColor = matchesRecommendation ? UIColors.Get("LightlessGreen") : UIColors.Get("LightlessBlue"); var selectedDescription = hasSelectedInfo ? selectedInfo!.Description : matchesRecommendation ? "Selected target matches the automatic recommendation." : "Manual selection without additional guidance."; DrawCompressionCard("Selected Target", selectedLabelText(), selectedTitleColor, selectedDescription); if (row.SuggestedTarget.HasValue) { var recommendedTarget = row.SuggestedTarget.Value; var hasRecommendationInfo = TextureMetadataHelper.TryGetRecommendationInfo(recommendedTarget, out var recommendedInfo); var recommendedTitle = hasRecommendationInfo ? recommendedInfo!.Title : recommendedTarget.ToString(); var recommendedDescription = hasRecommendationInfo ? recommendedInfo!.Description : string.IsNullOrEmpty(row.SuggestionReason) ? "No additional context provided." : row.SuggestionReason; DrawCompressionCard("Recommended Target", recommendedTitle, UIColors.Get("LightlessYellow"), recommendedDescription); } ImGui.EndTable(); } } using (ImRaii.PushIndent(12f * scale)) { if (!row.SuggestedTarget.HasValue) { UiSharedService.ColorTextWrapped("No automatic recommendation available.", UIColors.Get("LightlessYellow")); } if (!matchesRecommendation && row.SuggestedTarget.HasValue) { UiSharedService.ColorTextWrapped("Selected compression differs from the recommendation. Review before running batch conversion.", UIColors.Get("DimRed")); } } string selectedLabelText() { if (hasSelectedInfo) { return selectedInfo!.Title; } return selectedTarget.ToString(); } void DrawCompressionCard(string header, string title, Vector4 titleColor, string body) { ImGui.TableNextColumn(); UiSharedService.ColorText(header, UIColors.Get("LightlessPurple")); using (ImRaii.PushColor(ImGuiCol.Text, titleColor)) { ImGui.TextUnformatted(title); } UiSharedService.TextWrapped(body, 0, ImGuiColors.DalamudGrey); } } void DrawExpandableList(string title, IReadOnlyList entries, string emptyMessage) { _ = 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) { 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 SortCachedAnalysis( ObjectKind objectKind, Func, TKey> selector, bool ascending, IComparer? comparer = null) { if (_cachedAnalysis == null || !_cachedAnalysis.TryGetValue(objectKind, out var current)) { return; } var ordered = ascending ? (comparer != null ? current.OrderBy(selector, comparer) : current.OrderBy(selector)) : (comparer != null ? current.OrderByDescending(selector, comparer) : current.OrderByDescending(selector)); _cachedAnalysis[objectKind] = ordered.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); } private void DrawTable(IGrouping fileGroup) { 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.PadOuterX | ImGuiTableFlags.NoHostExtendX, new Vector2(-1f, 0f)); if (!table.Success) { return; } if (isModel) { 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); ImGui.TableHeadersRow(); var sortSpecs = ImGui.TableGetSortSpecs(); if (sortSpecs.SpecsDirty && sortSpecs.SpecsCount > 0) { var spec = sortSpecs.Specs[0]; bool ascending = spec.SortDirection == ImGuiSortDirection.Ascending; var columnIndex = (int)spec.ColumnIndex; if (isModel) { 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); } 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; } IEnumerable entries = fileGroup; if (isModel && !string.IsNullOrWhiteSpace(_modelSearch)) { 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))); } foreach (var item in entries) { var isSelected = string.Equals(item.Hash, _selectedHash, StringComparison.Ordinal); var defaultTextColor = ImGui.GetColorU32(ImGuiCol.Text); if (isModel) { ImGui.TableNextColumn(); 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); } } }