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.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 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.NoInputs; private readonly List _uiRects = new(128); private ImmutableHashSet _activeBroadcastingCids = []; 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)); } 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) { 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; var pNameplateAddon = (AddonNamePlate*)args.Addon.Address; if (_mpNameplateAddon != pNameplateAddon) { ClearNameplateCaches(); _mpNameplateAddon = pNameplateAddon; } UpdateNameplateNodes(); } /// /// Updates the nameplate nodes with LightFinder objects. /// private void UpdateNameplateNodes() { 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 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 var scaleMultiplier = System.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)System.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)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale); var nodeHeight = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale); AlignmentType alignment; var textScaleY = nameText->AtkResNode.ScaleY; if (textScaleY <= 0f) textScaleY = 1f; var blockHeight = ResolveCache( _buffers.TextHeights, nameplateIndex, System.Math.Abs((int)nameplateObject.TextH), () => GetScaledTextHeight(nameText), nodeHeight); var containerHeight = ResolveCache( _buffers.ContainerHeights, nameplateIndex, (int)nameContainer->Height, () => { var computed = blockHeight + (int)System.Math.Round(8 * textScaleY); return computed <= blockHeight ? blockHeight + 1 : computed; }, blockHeight + 1); var blockTop = containerHeight - blockHeight; if (blockTop < 0) blockTop = 0; var verticalPadding = (int)System.Math.Round(4 * effectiveScale); var positionY = blockTop - verticalPadding; var rawTextWidth = (int)nameplateObject.TextW; var textWidth = ResolveCache( _buffers.TextWidths, nameplateIndex, System.Math.Abs(rawTextWidth), () => GetScaledTextWidth(nameText), nodeWidth); // Text offset caching var textOffset = (int)System.Math.Round(nameText->AtkResNode.X); var hasValidOffset = TryCacheTextOffset(nameplateIndex, rawTextWidth, textOffset); if (nameContainer == null) { if (_logger.IsEnabled(LogLevel.Debug)) _logger.LogDebug("Nameplate {Index} container became unavailable during update, skipping.", nameplateIndex); continue; } 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 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 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; // often same for Y var fw = Framework.Instance(); float dt = fw->RealFrameDeltaTime; //smoothing.. finalPosition = SnapToPixels(finalPosition, dpiScale); finalPosition = SmoothPosition(nameplateIndex, finalPosition, dt); finalPosition = SnapToPixels(finalPosition, dpiScale); // prepare label info 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() { if (!_mEnabled) return; var fw = Framework.Instance(); if (fw == null) return; // Frame skip check var frame = fw->FrameCounter; if (_lastNamePlateDrawFrame == 0 || (frame - _lastNamePlateDrawFrame) > 1) { ClearLabelBuffer(); Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length); return; } //Gpose Check if (_clientState.IsGPosing) { ClearLabelBuffer(); Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length); _lastNamePlateDrawFrame = 0; return; } // If nameplate addon is not visible, skip rendering if (!IsNamePlateAddonVisible()) return; int copyCount; lock (_labelLock) { copyCount = _labelRenderCount; if (copyCount == 0) return; Array.Copy(_buffers.LabelRender, _buffers.LabelCopy, copyCount); } var uiModule = fw != null ? fw->GetUIModule() : null; 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); using var drawList = PictoService.Draw(); if (drawList == null) { ImGui.End(); return; } for (int i = 0; i < copyCount; ++i) { ref var info = ref _buffers.LabelCopy[i]; // final draw position with viewport offset var drawPos = info.ScreenPosition + 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; // label rect for occlusion checking 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); // occlusion check if (IsOccludedByAnyUi(labelRect)) continue; drawList.AddScreenText(drawPos, info.Text, info.TextColor, info.FontSize, info.Pivot, info.EdgeColor, font); } } 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)System.Math.Round(rawHeight * scale); return System.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. /// /// Position /// 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. /// /// Nameplate Index /// Final position /// Delta Time /// How responssive the smooting should be /// 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; } /// /// Tries to get a valid screen rect for the given addon. /// /// Addon UI /// Screen positioning/param> /// RectF of Addon /// private static bool TryGetAddonRect(AtkUnitBase* addon, Vector2 screen, out RectF rect) { // Addon existence rect = default; if (addon == null) return false; // Visibility check var root = addon->RootNode; if (root == null || !root->IsVisible()) return false; // Size check float w = root->Width; float h = root->Height; if (w <= 0 || h <= 0) return false; // Local scale float sx = root->ScaleX; if (sx <= 0f) sx = 1f; float sy = root->ScaleY; if (sy <= 0f) sy = 1f; // World/composed scale from Transform float wsx = GetWorldScaleX(root); float wsy = GetWorldScaleY(root); if (wsx <= 0f) wsx = 1f; if (wsy <= 0f) wsy = 1f; // World scale may include parent scaling; use it if meaningfully different. float useX = MathF.Abs(wsx - sx) > 0.01f ? wsx : sx; float useY = MathF.Abs(wsy - sy) > 0.01f ? wsy : sy; w *= useX; h *= useY; if (w < 4f || h < 4f) return false; // Screen coords float l = root->ScreenX; float t = root->ScreenY; float r = l + w; float b = t + h; // Drop fullscreen-ish / insane rects if (w >= screen.X * 0.98f && h >= screen.Y * 0.98f) return false; // Drop offscreen rects if (l < -screen.X || t < -screen.Y || r > screen.X * 2f || b > screen.Y * 2f) return false; rect = new RectF(l, t, r, b); return true; } /// /// Refreshes the cached UI rects for occlusion checking. /// /// Unit Manager 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); } } /// /// Is the given label rect occluded by any UI rects? /// /// UI/Label Rect /// Is occluded or not 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. /// /// Node /// World Scale of 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. /// /// Node /// World Scale of 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. /// /// Raw glyph input /// Normalized glyph input 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? /// /// Is it visible? private bool IsNamePlateAddonVisible() { if (_mpNameplateAddon == null) return false; var root = _mpNameplateAddon->AtkUnitBase.RootNode; return root != null && root->IsVisible(); } /// /// Converts raw icon glyph input into an icon editor string. /// /// Raw icon glyph input /// Icon editor string internal static string ToIconEditorString(string? rawInput) { var normalized = NormalizeIconGlyph(rawInput); var runeEnumerator = normalized.EnumerateRunes(); return runeEnumerator.MoveNext() ? runeEnumerator.Current.Value.ToString("X4", CultureInfo.InvariantCulture) : _defaultIconGlyph; } 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 void FlagRefresh() { _needsLabelRefresh = true; } public void OnTick(PriorityFrameworkUpdateMessage _) { if (_needsLabelRefresh) { UpdateNameplateNodes(); _needsLabelRefresh = false; } } /// /// Update the active broadcasting CIDs. /// /// Inbound new 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]; System.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() { System.Array.Clear(TextWidths, 0, TextWidths.Length); System.Array.Clear(TextHeights, 0, TextHeights.Length); System.Array.Clear(ContainerHeights, 0, ContainerHeights.Length); System.Array.Fill(TextOffsets, int.MinValue); } } /// /// Starts the LightFinder Plate Handler. /// /// Cancellation Token /// Task Completed public Task StartAsync(CancellationToken cancellationToken) { Init(); return Task.CompletedTask; } /// /// Stops the LightFinder Plate Handler. /// /// Cancellation Token /// Task Completed 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); } }