using Dalamud.Bindings.ImGui; using Dalamud.Game.Addon.Lifecycle; using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; using Dalamud.Game.ClientState.Objects.Enums; using Dalamud.Game.Text; using Dalamud.Interface; using Dalamud.Interface.Utility; using Dalamud.Plugin; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.System.Framework; using FFXIVClientStructs.FFXIV.Client.UI; using FFXIVClientStructs.FFXIV.Component.GUI; using LightlessSync.LightlessConfiguration; using LightlessSync.Services.Mediator; using LightlessSync.Services.Rendering; using LightlessSync.UI; using LightlessSync.UI.Services; using LightlessSync.Utils; using LightlessSync.UtilsEnum.Enum; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Pictomancy; using System.Collections.Immutable; using System.Globalization; using System.Numerics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Task = System.Threading.Tasks.Task; namespace LightlessSync.Services.LightFinder; /// /// The new lightfinder nameplate handler using ImGUI (pictomancy) for rendering the icon/labels. /// public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscriber { private readonly ILogger _logger; private readonly IAddonLifecycle _addonLifecycle; private readonly IGameGui _gameGui; private readonly IObjectTable _objectTable; private readonly IClientState _clientState; private readonly LightlessConfigService _configService; private readonly PairUiService _pairUiService; private readonly LightlessMediator _mediator; public LightlessMediator Mediator => _mediator; private readonly IUiBuilder _uiBuilder; private bool _mEnabled; private bool _needsLabelRefresh; private bool _drawSubscribed; private AddonNamePlate* _mpNameplateAddon; private readonly Lock _labelLock = new(); private readonly NameplateBuffers _buffers = new(); private int _labelRenderCount; private LightfinderLabelRenderer _lastRenderer; private const string _defaultLabelText = "LightFinder"; private const SeIconChar _defaultIcon = SeIconChar.Hyadelyn; private static readonly string _defaultIconGlyph = SeIconCharExtensions.ToIconString(_defaultIcon); private static readonly Vector2 _defaultPivot = new(0.5f, 1f); private uint _lastNamePlateDrawFrame; // / Overlay window flags private const ImGuiWindowFlags _overlayFlags = ImGuiWindowFlags.NoDecoration | ImGuiWindowFlags.NoBackground | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoNav | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoInputs; private readonly List _uiRects = new(128); private ImmutableHashSet _activeBroadcastingCids = []; #if DEBUG // Debug controls // Debug counters (read-only from UI) #endif private bool IsPictomancyRenderer => _configService.Current.LightfinderLabelRenderer == LightfinderLabelRenderer.Pictomancy; public LightFinderPlateHandler( ILogger logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, IClientState clientState, LightlessConfigService configService, LightlessMediator mediator, IObjectTable objectTable, PairUiService pairUiService, IDalamudPluginInterface pluginInterface, PictomancyService pictomancyService) { _logger = logger; _addonLifecycle = addonLifecycle; _gameGui = gameGui; _clientState = clientState; _configService = configService; _mediator = mediator; _objectTable = objectTable; _pairUiService = pairUiService; _uiBuilder = pluginInterface.UiBuilder ?? throw new ArgumentNullException(nameof(pluginInterface)); _ = pictomancyService ?? throw new ArgumentNullException(nameof(pictomancyService)); _lastRenderer = _configService.Current.LightfinderLabelRenderer; } private void RefreshRendererState() { var renderer = _configService.Current.LightfinderLabelRenderer; if (renderer == _lastRenderer) return; _lastRenderer = renderer; if (renderer == LightfinderLabelRenderer.Pictomancy) { FlagRefresh(); } else { ClearNameplateCaches(); _lastNamePlateDrawFrame = 0; } } internal void Init() { if (!_drawSubscribed) { _uiBuilder.Draw += OnUiBuilderDraw; _drawSubscribed = true; } EnableNameplate(); _mediator.Subscribe(this, OnTick); } internal void Uninit() { DisableNameplate(); if (_drawSubscribed) { _uiBuilder.Draw -= OnUiBuilderDraw; _drawSubscribed = false; } ClearLabelBuffer(); _mediator.Unsubscribe(this); _mpNameplateAddon = null; } /// /// Enable nameplate handling. /// internal void EnableNameplate() { if (!_mEnabled) { try { _addonLifecycle.RegisterListener(AddonEvent.PostDraw, "NamePlate", NameplateDrawDetour); _mEnabled = true; } catch (Exception e) { _logger.LogError(e, "Unknown error while trying to enable nameplate."); DisableNameplate(); } } } /// /// Disable nameplate handling. /// internal void DisableNameplate() { if (_mEnabled) { try { _addonLifecycle.UnregisterListener(NameplateDrawDetour); } catch (Exception e) { _logger.LogError(e, "Unknown error while unregistering nameplate listener."); } _mEnabled = false; ClearNameplateCaches(); } } /// /// Draw detour for nameplate addon. /// private void NameplateDrawDetour(AddonEvent type, AddonArgs args) { RefreshRendererState(); if (!IsPictomancyRenderer) { ClearLabelBuffer(); _lastNamePlateDrawFrame = 0; return; } // Hide our overlay when the user hides the entire game UI (ScrollLock). if (_gameGui.GameUiHidden) { ClearLabelBuffer(); Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length); _lastNamePlateDrawFrame = 0; return; } // gpose: do not draw. if (_clientState.IsGPosing) { ClearLabelBuffer(); Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length); _lastNamePlateDrawFrame = 0; return; } if (args.Addon.Address == nint.Zero) { if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning("Nameplate draw detour received a null addon address, skipping update."); return; } var fw = Framework.Instance(); if (fw != null) _lastNamePlateDrawFrame = fw->FrameCounter; #if DEBUG DebugLastNameplateFrame = _lastNamePlateDrawFrame; #endif var pNameplateAddon = (AddonNamePlate*)args.Addon.Address; if (_mpNameplateAddon != pNameplateAddon) { ClearNameplateCaches(); _mpNameplateAddon = pNameplateAddon; } UpdateNameplateNodes(); } /// /// Updates the nameplate nodes with LightFinder objects. /// private void UpdateNameplateNodes() { // If the user has hidden the UI, don't compute any labels. if (_gameGui.GameUiHidden) { ClearLabelBuffer(); return; } var currentHandle = _gameGui.GetAddonByName("NamePlate"); if (currentHandle.Address == nint.Zero) { if (_logger.IsEnabled(LogLevel.Debug)) _logger.LogDebug("NamePlate addon unavailable during update, skipping label refresh."); ClearLabelBuffer(); return; } var currentAddon = (AddonNamePlate*)currentHandle.Address; if (_mpNameplateAddon == null || currentAddon == null || currentAddon != _mpNameplateAddon) { if (_mpNameplateAddon != null && _logger.IsEnabled(LogLevel.Debug)) _logger.LogDebug("Cached NamePlate addon pointer differs from current: waiting for new hook (cached {Cached}, current {Current}).", (IntPtr)_mpNameplateAddon, (IntPtr)currentAddon); return; } if (!IsNamePlateAddonVisible()) { ClearLabelBuffer(); return; } var framework = Framework.Instance(); if (framework == null) { if (_logger.IsEnabled(LogLevel.Debug)) _logger.LogDebug("Framework instance unavailable during nameplate update, skipping."); return; } var uiModule = framework->GetUIModule(); if (uiModule == null) { if (_logger.IsEnabled(LogLevel.Debug)) _logger.LogDebug("UI module unavailable during nameplate update, skipping."); return; } var ui3DModule = uiModule->GetUI3DModule(); if (ui3DModule == null) { if (_logger.IsEnabled(LogLevel.Debug)) _logger.LogDebug("UI3D module unavailable during nameplate update, skipping."); return; } var vec = ui3DModule->NamePlateObjectInfoPointers; if (vec.IsEmpty) { ClearLabelBuffer(); return; } var visibleUserIdsSnapshot = VisibleUserIds; var safeCount = Math.Min(ui3DModule->NamePlateObjectInfoCount, vec.Length); var currentConfig = _configService.Current; var labelColor = UIColors.Get("Lightfinder"); var edgeColor = UIColors.Get("LightfinderEdge"); var scratchCount = 0; for (int i = 0; i < safeCount; ++i) { var objectInfoPtr = vec[i]; if (objectInfoPtr == null) continue; var objectInfo = objectInfoPtr.Value; if (objectInfo == null || objectInfo->GameObject == null) continue; var nameplateIndex = objectInfo->NamePlateIndex; if (nameplateIndex < 0 || nameplateIndex >= AddonNamePlate.NumNamePlateObjects) continue; var gameObject = objectInfo->GameObject; if ((ObjectKind)gameObject->ObjectKind != ObjectKind.Player) continue; // CID gating - only show for active broadcasters var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject); if (cid == null || !_activeBroadcastingCids.Contains(cid)) continue; var local = _objectTable.LocalPlayer; if (!currentConfig.LightfinderLabelShowOwn && local != null && objectInfo->GameObject->GetGameObjectId() == local.GameObjectId) continue; var hidePaired = !currentConfig.LightfinderLabelShowPaired; var goId = gameObject->GetGameObjectId(); if (hidePaired && visibleUserIdsSnapshot.Contains(goId)) continue; var nameplateObject = _mpNameplateAddon->NamePlateObjectArray[nameplateIndex]; var root = nameplateObject.RootComponentNode; var nameContainer = nameplateObject.NameContainer; var nameText = nameplateObject.NameText; if (root == null || root->Component == null || nameContainer == null || nameText == null) { if (_logger.IsEnabled(LogLevel.Debug)) _logger.LogDebug("Nameplate {Index} missing required nodes during update, skipping.", nameplateIndex); continue; } root->Component->UldManager.UpdateDrawNodeList(); bool isNameplateVisible = nameContainer->IsVisible() && nameText->AtkResNode.IsVisible(); if (!currentConfig.LightfinderLabelShowHidden && !isNameplateVisible) continue; // Prepare label content and scaling factors var scaleMultiplier = Math.Clamp(currentConfig.LightfinderLabelScale, 0.5f, 2.0f); var baseScale = currentConfig.LightfinderLabelUseIcon ? 1.0f : 0.5f; var effectiveScale = baseScale * scaleMultiplier; var baseFontSize = currentConfig.LightfinderLabelUseIcon ? 36f : 24f; var targetFontSize = (int)Math.Round(baseFontSize * scaleMultiplier); var labelContent = currentConfig.LightfinderLabelUseIcon ? NormalizeIconGlyph(currentConfig.LightfinderLabelIconGlyph) : _defaultLabelText; if (!currentConfig.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal))) labelContent = _defaultLabelText; var nodeWidth = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale); var nodeHeight = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale); AlignmentType alignment; var textScaleY = nameText->AtkResNode.ScaleY; if (textScaleY <= 0f) textScaleY = 1f; var blockHeight = ResolveCache( _buffers.TextHeights, nameplateIndex, Math.Abs((int)nameplateObject.TextH), () => GetScaledTextHeight(nameText), nodeHeight); var containerHeight = ResolveCache( _buffers.ContainerHeights, nameplateIndex, (int)nameContainer->Height, () => { var computed = blockHeight + (int)Math.Round(8 * textScaleY); return computed <= blockHeight ? blockHeight + 1 : computed; }, blockHeight + 1); var blockTop = containerHeight - blockHeight; if (blockTop < 0) blockTop = 0; var verticalPadding = (int)Math.Round(4 * effectiveScale); var positionY = blockTop - verticalPadding; var rawTextWidth = (int)nameplateObject.TextW; var textWidth = ResolveCache( _buffers.TextWidths, nameplateIndex, Math.Abs(rawTextWidth), () => GetScaledTextWidth(nameText), nodeWidth); // Text offset caching var textOffset = (int)Math.Round(nameText->AtkResNode.X); var hasValidOffset = TryCacheTextOffset(nameplateIndex, rawTextWidth, textOffset); var res = nameContainer; // X scale var worldScaleX = GetWorldScaleX(res); if (worldScaleX <= 0f) worldScaleX = 1f; // Y scale var worldScaleY = GetWorldScaleY(res); if (worldScaleY <= 0f) worldScaleY = 1f; positionY += currentConfig.LightfinderLabelOffsetY; var positionYScreen = positionY * worldScaleY; float finalX; if (currentConfig.LightfinderAutoAlign) { // auto X positioning var measuredWidth = Math.Max(1, textWidth > 0 ? textWidth : nodeWidth); var measuredWidthF = (float)measuredWidth; // consider icon width var containerWidthLocal = res->Width > 0 ? res->Width : measuredWidthF; var containerWidthScreen = containerWidthLocal * worldScaleX; // container bounds for positions var containerLeft = res->ScreenX; var containerRight = containerLeft + containerWidthScreen; var containerCenter = containerLeft + (containerWidthScreen * 0.5f); var iconMargin = currentConfig.LightfinderLabelUseIcon ? MathF.Min(containerWidthScreen * 0.1f, 14f * worldScaleX) : 0f; var offsetXScreen = currentConfig.LightfinderLabelOffsetX * worldScaleX; // alignment based on config setting switch (currentConfig.LabelAlignment) { case LabelAlignment.Left: finalX = containerLeft + iconMargin + offsetXScreen; alignment = AlignmentType.BottomLeft; break; case LabelAlignment.Right: finalX = containerRight - iconMargin + offsetXScreen; alignment = AlignmentType.BottomRight; break; default: finalX = containerCenter + offsetXScreen; alignment = AlignmentType.Bottom; break; } } else { // manual X positioning with optional cached offset var cachedTextOffset = _buffers.TextOffsets[nameplateIndex]; var hasCachedOffset = cachedTextOffset != int.MinValue; var baseOffsetXLocal = (!currentConfig.LightfinderLabelUseIcon && hasValidOffset && hasCachedOffset) ? cachedTextOffset : 0; finalX = res->ScreenX + (baseOffsetXLocal * worldScaleX) + (58f * worldScaleX) + (currentConfig.LightfinderLabelOffsetX * worldScaleX); alignment = AlignmentType.Bottom; } alignment = (AlignmentType)Math.Clamp((int)alignment, 0, 8); // final position before smoothing var finalPosition = new Vector2(finalX, res->ScreenY + positionYScreen); var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; var fw = Framework.Instance(); float dt = fw->RealFrameDeltaTime; //smoothing.. snap.. smooth.. snap finalPosition = SnapToPixels(finalPosition, dpiScale); finalPosition = SmoothPosition(nameplateIndex, finalPosition, dt); finalPosition = SnapToPixels(finalPosition, dpiScale); // prepare label info for rendering var pivot = (currentConfig.LightfinderAutoAlign || currentConfig.LightfinderLabelUseIcon) ? AlignmentToPivot(alignment) : _defaultPivot; var textColorPacked = PackColor(labelColor); var edgeColorPacked = PackColor(edgeColor); _buffers.LabelScratch[scratchCount++] = new NameplateLabelInfo( finalPosition, labelContent, textColorPacked, edgeColorPacked, targetFontSize, pivot, currentConfig.LightfinderLabelUseIcon); } lock (_labelLock) { if (scratchCount == 0) { _labelRenderCount = 0; } else { Array.Copy(_buffers.LabelScratch, _buffers.LabelRender, scratchCount); _labelRenderCount = scratchCount; } } } /// /// On each tick, process any needed updates for the UI Builder. /// private void OnUiBuilderDraw() { RefreshRendererState(); if (!IsPictomancyRenderer) return; if (!_mEnabled) return; var fw = Framework.Instance(); if (fw == null) return; // If UI is hidden, do not render. if (_gameGui.GameUiHidden) { ClearLabelBuffer(); Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length); _lastNamePlateDrawFrame = 0; #if DEBUG DebugLabelCountLastFrame = 0; DebugUiRectCountLastFrame = 0; DebugOccludedCountLastFrame = 0; DebugLastNameplateFrame = 0; #endif return; } // Frame skip check - skip if more than 1 frame has passed since last nameplate draw. var frame = fw->FrameCounter; if (_lastNamePlateDrawFrame == 0 || (frame - _lastNamePlateDrawFrame) > 1) { ClearLabelBuffer(); Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length); #if DEBUG DebugLabelCountLastFrame = 0; DebugUiRectCountLastFrame = 0; DebugOccludedCountLastFrame = 0; DebugLastNameplateFrame = _lastNamePlateDrawFrame; #endif return; } // Gpose Check - do not render. if (_clientState.IsGPosing) { ClearLabelBuffer(); Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length); _lastNamePlateDrawFrame = 0; #if DEBUG DebugLabelCountLastFrame = 0; DebugUiRectCountLastFrame = 0; DebugOccludedCountLastFrame = 0; DebugLastNameplateFrame = 0; #endif return; } // If nameplate addon is not visible, skip rendering entirely. if (!IsNamePlateAddonVisible()) { #if DEBUG DebugLabelCountLastFrame = 0; DebugUiRectCountLastFrame = 0; DebugOccludedCountLastFrame = 0; DebugLastNameplateFrame = _lastNamePlateDrawFrame; #endif return; } int copyCount; lock (_labelLock) { copyCount = _labelRenderCount; if (copyCount == 0) { #if DEBUG DebugLabelCountLastFrame = 0; DebugUiRectCountLastFrame = 0; DebugOccludedCountLastFrame = 0; DebugLastNameplateFrame = _lastNamePlateDrawFrame; #endif return; } Array.Copy(_buffers.LabelRender, _buffers.LabelCopy, copyCount); } var uiModule = fw->GetUIModule(); if (uiModule != null) { var rapture = uiModule->GetRaptureAtkModule(); if (rapture != null) RefreshUiRects(&rapture->RaptureAtkUnitManager); else _uiRects.Clear(); } else { _uiRects.Clear(); } // Needed for imgui overlay viewport for the multi window view. var vp = ImGui.GetMainViewport(); var vpPos = vp.Pos; ImGuiHelpers.ForceNextWindowMainViewport(); ImGui.SetNextWindowPos(vp.Pos); ImGui.SetNextWindowSize(vp.Size); ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 0); ImGui.PushStyleVar(ImGuiStyleVar.WindowBorderSize, 0); ImGui.Begin("##LightFinderOverlay", _overlayFlags); ImGui.PopStyleVar(2); // Debug flags bool dbgEnabled = false; bool dbgDisableOcc = false; bool dbgDrawUiRects = false; bool dbgDrawLabelRects = false; #if DEBUG dbgEnabled = DebugEnabled; dbgDisableOcc = DebugDisableOcclusion; dbgDrawUiRects = DebugDrawUiRects; dbgDrawLabelRects = DebugDrawLabelRects; #endif int occludedThisFrame = 0; try { using var drawList = PictoService.Draw(); if (drawList == null) return; // Debug drawing uses the window drawlist (so it always draws in the correct viewport). var dbgDl = ImGui.GetWindowDrawList(); var useViewportOffset = ImGui.GetIO().ConfigFlags.HasFlag(ImGuiConfigFlags.ViewportsEnable); for (int i = 0; i < copyCount; ++i) { ref var info = ref _buffers.LabelCopy[i]; // final draw position with viewport offset (only when viewports are enabled) var drawPos = info.ScreenPosition; if (useViewportOffset) drawPos += vpPos; var font = default(ImFontPtr); if (info.UseIcon) { var ioFonts = ImGui.GetIO().Fonts; font = ioFonts.Fonts.Size > 1 ? new ImFontPtr(ioFonts.Fonts[1]) : ImGui.GetFont(); } else { font = ImGui.GetFont(); } if (!font.IsNull) ImGui.PushFont(font); // calculate size for occlusion checking var baseSize = ImGui.CalcTextSize(info.Text); var baseFontSize = ImGui.GetFontSize(); if (!font.IsNull) ImGui.PopFont(); // scale size based on font size var scale = baseFontSize > 0 ? (info.FontSize / baseFontSize) : 1f; var size = baseSize * scale; var topLeft = info.ScreenPosition - new Vector2(size.X * info.Pivot.X, size.Y * info.Pivot.Y); var labelRect = new RectF(topLeft.X, topLeft.Y, topLeft.X + size.X, topLeft.Y + size.Y); bool wouldOcclude = IsOccludedByAnyUi(labelRect); if (wouldOcclude) occludedThisFrame++; // Debug: draw label rects if (dbgEnabled && dbgDrawLabelRects) { var tl = new Vector2(labelRect.L, labelRect.T); var br = new Vector2(labelRect.R, labelRect.B); if (useViewportOffset) { tl += vpPos; br += vpPos; } // green = visible, red = would be occluded (even if forced) var col = wouldOcclude ? ImGui.GetColorU32(new Vector4(1f, 0f, 0f, 0.6f)) : ImGui.GetColorU32(new Vector4(0f, 1f, 0f, 0.6f)); dbgDl.AddRect(tl, br, col); } // occlusion check (allow debug to disable) if (!dbgDisableOcc && wouldOcclude) continue; drawList.AddScreenText(drawPos, info.Text, info.TextColor, info.FontSize, info.Pivot, info.EdgeColor, font); } // Debug: draw UI rects if any if (dbgEnabled && dbgDrawUiRects && _uiRects.Count > 0) { var useOff = useViewportOffset ? vpPos : Vector2.Zero; var col = ImGui.GetColorU32(new Vector4(1f, 1f, 1f, 0.35f)); for (int i = 0; i < _uiRects.Count; i++) { var r = _uiRects[i]; dbgDl.AddRect(new Vector2(r.L, r.T) + useOff, new Vector2(r.R, r.B) + useOff, col); } } } finally { ImGui.End(); } #if DEBUG DebugLabelCountLastFrame = copyCount; DebugUiRectCountLastFrame = _uiRects.Count; DebugOccludedCountLastFrame = occludedThisFrame; DebugLastNameplateFrame = _lastNamePlateDrawFrame; #endif } private static Vector2 AlignmentToPivot(AlignmentType alignment) => alignment switch { AlignmentType.BottomLeft => new Vector2(0f, 1f), AlignmentType.BottomRight => new Vector2(1f, 1f), AlignmentType.TopLeft => new Vector2(0f, 0f), AlignmentType.TopRight => new Vector2(1f, 0f), AlignmentType.Top => new Vector2(0.5f, 0f), AlignmentType.Left => new Vector2(0f, 0.5f), AlignmentType.Right => new Vector2(1f, 0.5f), _ => _defaultPivot }; private static uint PackColor(Vector4 color) { var r = (byte)Math.Clamp(color.X * 255f, 0f, 255f); var g = (byte)Math.Clamp(color.Y * 255f, 0f, 255f); var b = (byte)Math.Clamp(color.Z * 255f, 0f, 255f); var a = (byte)Math.Clamp(color.W * 255f, 0f, 255f); return (uint)((a << 24) | (b << 16) | (g << 8) | r); } private void ClearLabelBuffer() { lock (_labelLock) { _labelRenderCount = 0; } } private static unsafe int GetScaledTextHeight(AtkTextNode* node) { if (node == null) return 0; var resNode = &node->AtkResNode; var rawHeight = (int)resNode->GetHeight(); if (rawHeight <= 0 && node->LineSpacing > 0) rawHeight = node->LineSpacing; if (rawHeight <= 0) rawHeight = AtkNodeHelpers.DefaultTextNodeHeight; var scale = resNode->ScaleY; if (scale <= 0f) scale = 1f; var computed = (int)Math.Round(rawHeight * scale); return Math.Max(1, computed); } private static unsafe int GetScaledTextWidth(AtkTextNode* node) { if (node == null) return 0; var resNode = &node->AtkResNode; var rawWidth = (int)resNode->GetWidth(); if (rawWidth <= 0) rawWidth = AtkNodeHelpers.DefaultTextNodeWidth; var scale = resNode->ScaleX; if (scale <= 0f) scale = 1f; var computed = (int)Math.Round(rawWidth * scale); return Math.Max(1, computed); } /// /// Resolves a cached value for the given index. /// private static int ResolveCache( int[] cache, int index, int rawValue, Func fallback, int fallbackWhenZero) { if (rawValue > 0) { cache[index] = rawValue; return rawValue; } var cachedValue = cache[index]; if (cachedValue > 0) return cachedValue; var computed = fallback(); if (computed <= 0) computed = fallbackWhenZero; cache[index] = computed; return computed; } private bool TryCacheTextOffset(int nameplateIndex, int measuredTextWidth, int textOffset) { if (Math.Abs(measuredTextWidth) > 0 || textOffset != 0) { _buffers.TextOffsets[nameplateIndex] = textOffset; return true; } return false; } /// /// Snapping a position to pixel grid based on DPI scale. /// private static Vector2 SnapToPixels(Vector2 p, float dpiScale) { // snap to pixel grid var x = MathF.Round(p.X * dpiScale) / dpiScale; var y = MathF.Round(p.Y * dpiScale) / dpiScale; return new Vector2(x, y); } /// /// Smooths the position using exponential smoothing. /// private Vector2 SmoothPosition(int idx, Vector2 target, float dt, float responsiveness = 24f) { // exponential smoothing if (!_buffers.HasSmoothed[idx]) { _buffers.HasSmoothed[idx] = true; _buffers.SmoothedPos[idx] = target; return target; } // get current smoothed position var cur = _buffers.SmoothedPos[idx]; // compute smoothing factor var a = 1f - MathF.Exp(-responsiveness * dt); // snap if close enough if (Vector2.DistanceSquared(cur, target) < 0.25f) return cur; // lerp towards target cur = Vector2.Lerp(cur, target, a); _buffers.SmoothedPos[idx] = cur; return cur; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool IsFinite(float f) => !(float.IsNaN(f) || float.IsInfinity(f)); private static bool TryGetAddonRect(AtkUnitBase* addon, Vector2 screen, out RectF rect) { rect = default; if (addon == null) return false; // Addon must be visible if (!addon->IsVisible) return false; // Root must be visible var root = addon->RootNode; if (root == null || !root->IsVisible()) return false; // Must have multiple nodes to be useful var nodeCount = addon->UldManager.NodeListCount; var nodeList = addon->UldManager.NodeList; if (nodeCount <= 1 || nodeList == null) return false; float rsx = GetWorldScaleX(root); float rsy = GetWorldScaleY(root); if (!IsFinite(rsx) || rsx <= 0f) rsx = 1f; if (!IsFinite(rsy) || rsy <= 0f) rsy = 1f; // clamp insane root scales (rare but prevents explosions) rsx = MathF.Min(rsx, 6f); rsy = MathF.Min(rsy, 6f); float rw = root->Width * rsx; float rh = root->Height * rsy; if (!IsFinite(rw) || !IsFinite(rh) || rw <= 2f || rh <= 2f) return false; float rl = root->ScreenX; float rt = root->ScreenY; if (!IsFinite(rl) || !IsFinite(rt)) return false; float rr = rl + rw; float rb = rt + rh; // If root is basically fullscreen, it’s not a useful occluder for our purpose. if (rw >= screen.X * 0.98f && rh >= screen.Y * 0.98f) return false; // Clip root to screen so it stays sane float rootL = MathF.Max(0f, rl); float rootT = MathF.Max(0f, rt); float rootR = MathF.Min(screen.X, rr); float rootB = MathF.Min(screen.Y, rb); if (rootR <= rootL || rootB <= rootT) return false; // Root dimensions var rootW = rootR - rootL; var rootH = rootB - rootT; // Find union of all probably-drawable nodes intersecting root bool any = false; float l = float.MaxValue, t = float.MaxValue, r = float.MinValue, b = float.MinValue; // Allow a small bleed outside root; some addons draw small bits outside their root container. const float rootPad = 24f; float padL = rootL - rootPad; float padT = rootT - rootPad; float padR = rootR + rootPad; float padB = rootB + rootPad; for (int i = 1; i < nodeCount; i++) { var n = nodeList[i]; if (!IsProbablyDrawableNode(n)) continue; float w = n->Width; float h = n->Height; if (!IsFinite(w) || !IsFinite(h) || w <= 1f || h <= 1f) continue; float sx = GetWorldScaleX(n); float sy = GetWorldScaleY(n); if (!IsFinite(sx) || sx <= 0f) sx = 1f; if (!IsFinite(sy) || sy <= 0f) sy = 1f; sx = MathF.Min(sx, 6f); sy = MathF.Min(sy, 6f); w *= sx; h *= sy; if (!IsFinite(w) || !IsFinite(h) || w < 2f || h < 2f) continue; float nl = n->ScreenX; float nt = n->ScreenY; if (!IsFinite(nl) || !IsFinite(nt)) continue; float nr = nl + w; float nb = nt + h; // Must intersect root (with padding). This is the big mitigation. if (nr <= padL || nb <= padT || nl >= padR || nt >= padB) continue; // Reject nodes that are wildly larger than the root (common on targeting). if (w > rootW * 2.0f || h > rootH * 2.0f) continue; // Clip node to root and then to screen (prevents offscreen junk stretching union) float cl = MathF.Max(rootL, nl); float ct = MathF.Max(rootT, nt); float cr = MathF.Min(rootR, nr); float cb = MathF.Min(rootB, nb); cl = MathF.Max(0f, cl); ct = MathF.Max(0f, ct); cr = MathF.Min(screen.X, cr); cb = MathF.Min(screen.Y, cb); if (cr <= cl || cb <= ct) continue; any = true; if (cl < l) l = cl; if (ct < t) t = ct; if (cr > r) r = cr; if (cb > b) b = cb; } // If nothing usable, fallback to root rect (still a sane occluder) if (!any) { rect = new RectF(rootL, rootT, rootR, rootB); return true; } // Validate final union rect var uw = r - l; var uh = b - t; if (uw < 4f || uh < 4f) { rect = new RectF(rootL, rootT, rootR, rootB); return true; } // If union is excessively larger than root, fallback to root rect if (uw > rootW * 1.35f || uh > rootH * 1.35f) { rect = new RectF(rootL, rootT, rootR, rootB); return true; } rect = new RectF(l, t, r, b); return true; } private static bool IsProbablyDrawableNode(AtkResNode* n) { if (n == null || !n->IsVisible()) return false; // Check alpha if (n->Color.A == 16) return false; // Check node type return n->Type switch { NodeType.Text => true, NodeType.Image => true, NodeType.NineGrid => true, NodeType.Counter => true, NodeType.Component => true, _ => false, }; } /// /// Refreshes the cached UI rects for occlusion checking. /// private void RefreshUiRects(RaptureAtkUnitManager* unitMgr) { _uiRects.Clear(); if (unitMgr == null) return; var screen = ImGui.GetIO().DisplaySize; ref var list = ref unitMgr->AllLoadedUnitsList; var count = (int)list.Count; for (int i = 0; i < count; i++) { var addon = list.Entries[i].Value; if (addon == null) continue; if (_mpNameplateAddon != null && addon == (AtkUnitBase*)_mpNameplateAddon) continue; if (TryGetAddonRect(addon, screen, out var r)) _uiRects.Add(r); } #if DEBUG DebugUiRectCountLastFrame = _uiRects.Count; #endif } /// /// Is the given label rect occluded by any UI rects? /// private bool IsOccludedByAnyUi(RectF labelRect) { for (int i = 0; i < _uiRects.Count; i++) { if (_uiRects[i].Intersects(labelRect)) return true; } return false; } /// /// Gets the world scale X of the given node. /// private static float GetWorldScaleX(AtkResNode* n) { var t = n->Transform; return MathF.Sqrt(t.M11 * t.M11 + t.M12 * t.M12); } /// /// Gets the world scale Y of the given node. /// private static float GetWorldScaleY(AtkResNode* n) { var t = n->Transform; return MathF.Sqrt(t.M21 * t.M21 + t.M22 * t.M22); } /// /// Normalize an icon glyph input into a valid string. /// internal static string NormalizeIconGlyph(string? rawInput) { if (string.IsNullOrWhiteSpace(rawInput)) return _defaultIconGlyph; var trimmed = rawInput.Trim(); if (Enum.TryParse(trimmed, true, out var iconEnum)) return SeIconCharExtensions.ToIconString(iconEnum); var hexCandidate = trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? trimmed[2..] : trimmed; if (ushort.TryParse(hexCandidate, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hexValue)) return char.ConvertFromUtf32(hexValue); var enumerator = trimmed.EnumerateRunes(); if (enumerator.MoveNext()) return enumerator.Current.ToString(); return _defaultIconGlyph; } /// /// Is the nameplate addon visible? /// private bool IsNamePlateAddonVisible() { if (_mpNameplateAddon == null) return false; var root = _mpNameplateAddon->AtkUnitBase.RootNode; return root != null && root->IsVisible(); } private readonly struct NameplateLabelInfo { public NameplateLabelInfo( Vector2 screenPosition, string text, uint textColor, uint edgeColor, float fontSize, Vector2 pivot, bool useIcon) { ScreenPosition = screenPosition; Text = text; TextColor = textColor; EdgeColor = edgeColor; FontSize = fontSize; Pivot = pivot; UseIcon = useIcon; } public Vector2 ScreenPosition { get; } public string Text { get; } public uint TextColor { get; } public uint EdgeColor { get; } public float FontSize { get; } public Vector2 Pivot { get; } public bool UseIcon { get; } } /// /// Visible paired user IDs snapshot. /// private HashSet VisibleUserIds => [.. _pairUiService.GetSnapshot().PairsByUid.Values .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) .Select(u => (ulong)u.PlayerCharacterId)]; public int DebugLabelCountLastFrame { get; set; } public int DebugUiRectCountLastFrame { get; set; } public int DebugOccludedCountLastFrame { get; set; } public uint DebugLastNameplateFrame { get; set; } public bool DebugDrawUiRects { get; set; } public bool DebugDrawLabelRects { get; set; } = true; public bool DebugDisableOcclusion { get; set; } public bool DebugEnabled { get; set; } public void FlagRefresh() { _needsLabelRefresh = true; } public void OnTick(PriorityFrameworkUpdateMessage _) { if (!IsPictomancyRenderer) { _needsLabelRefresh = false; return; } if (_needsLabelRefresh) { UpdateNameplateNodes(); _needsLabelRefresh = false; } } /// /// Update the active broadcasting CIDs. /// public void UpdateBroadcastingCids(IEnumerable cids) { var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal); if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet)) return; _activeBroadcastingCids = newSet; if (_logger.IsEnabled(LogLevel.Trace)) _logger.LogTrace("Active broadcast IDs: {Cids}", string.Join(',', _activeBroadcastingCids)); FlagRefresh(); } /// /// Clears all nameplate related caches. /// public void ClearNameplateCaches() { _buffers.Clear(); ClearLabelBuffer(); Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length); Array.Clear(_buffers.SmoothedPos, 0, _buffers.SmoothedPos.Length); } private sealed class NameplateBuffers { public NameplateBuffers() { TextOffsets = new int[AddonNamePlate.NumNamePlateObjects]; Array.Fill(TextOffsets, int.MinValue); } public int[] TextWidths { get; } = new int[AddonNamePlate.NumNamePlateObjects]; public int[] TextHeights { get; } = new int[AddonNamePlate.NumNamePlateObjects]; public int[] ContainerHeights { get; } = new int[AddonNamePlate.NumNamePlateObjects]; public int[] TextOffsets { get; } public NameplateLabelInfo[] LabelScratch { get; } = new NameplateLabelInfo[AddonNamePlate.NumNamePlateObjects]; public NameplateLabelInfo[] LabelRender { get; } = new NameplateLabelInfo[AddonNamePlate.NumNamePlateObjects]; public NameplateLabelInfo[] LabelCopy { get; } = new NameplateLabelInfo[AddonNamePlate.NumNamePlateObjects]; public Vector2[] SmoothedPos = new Vector2[AddonNamePlate.NumNamePlateObjects]; public bool[] HasSmoothed = new bool[AddonNamePlate.NumNamePlateObjects]; public void Clear() { Array.Clear(TextWidths, 0, TextWidths.Length); Array.Clear(TextHeights, 0, TextHeights.Length); Array.Clear(ContainerHeights, 0, ContainerHeights.Length); Array.Fill(TextOffsets, int.MinValue); } } /// /// Starts the LightFinder Plate Handler. /// public Task StartAsync(CancellationToken cancellationToken) { Init(); return Task.CompletedTask; } /// /// Stops the LightFinder Plate Handler. /// public Task StopAsync(CancellationToken cancellationToken) { Uninit(); return Task.CompletedTask; } /// /// Rectangle with float coordinates for intersection testing. /// [StructLayout(LayoutKind.Auto)] private readonly struct RectF { public readonly float L, T, R, B; public RectF(float l, float t, float r, float b) { L = l; T = t; R = r; B = b; } public bool Intersects(in RectF o) => !(R <= o.L || o.R <= L || B <= o.T || o.B <= T); } }