using Dalamud.Game.ClientState.Objects.Enums; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.System.Framework; using FFXIVClientStructs.FFXIV.Client.Game.Character; using FFXIVClientStructs.FFXIV.Client.UI; using FFXIVClientStructs.FFXIV.Component.GUI; using LightlessSync.LightlessConfiguration; using LightlessSync.Services.Mediator; using LightlessSync.UI; using LightlessSync.UI.Services; using LightlessSync.Utils; using LightlessSync.UtilsEnum.Enum; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using System.Collections.Immutable; using Task = System.Threading.Tasks.Task; namespace LightlessSync.Services.LightFinder; /// /// Native nameplate handler that injects LightFinder labels via the signature hook path. /// public unsafe class LightFinderNativePlateHandler : DisposableMediatorSubscriberBase, IHostedService { private const uint NameplateNodeIdBase = 0x7D99D500; private const string DefaultLabelText = "LightFinder"; private readonly ILogger _logger; private readonly IClientState _clientState; private readonly IObjectTable _objectTable; private readonly LightlessConfigService _configService; private readonly PairUiService _pairUiService; private readonly NameplateUpdateHookService _nameplateUpdateHookService; private readonly int[] _cachedNameplateTextWidths = new int[AddonNamePlate.NumNamePlateObjects]; private readonly int[] _cachedNameplateTextHeights = new int[AddonNamePlate.NumNamePlateObjects]; private readonly int[] _cachedNameplateContainerHeights = new int[AddonNamePlate.NumNamePlateObjects]; private readonly int[] _cachedNameplateTextOffsets = new int[AddonNamePlate.NumNamePlateObjects]; private readonly string?[] _lastLabelByIndex = new string?[AddonNamePlate.NumNamePlateObjects]; private ImmutableHashSet _activeBroadcastingCids = []; private LightfinderLabelRenderer _lastRenderer; private uint _lastSignatureUpdateFrame; private bool _isUpdating; private string _lastLabelContent = DefaultLabelText; public LightFinderNativePlateHandler( ILogger logger, IClientState clientState, LightlessConfigService configService, LightlessMediator mediator, IObjectTable objectTable, PairUiService pairUiService, NameplateUpdateHookService nameplateUpdateHookService) : base(logger, mediator) { _logger = logger; _clientState = clientState; _configService = configService; _objectTable = objectTable; _pairUiService = pairUiService; _nameplateUpdateHookService = nameplateUpdateHookService; _lastRenderer = _configService.Current.LightfinderLabelRenderer; Array.Fill(_cachedNameplateTextOffsets, int.MinValue); } private bool IsSignatureMode => _configService.Current.LightfinderLabelRenderer == LightfinderLabelRenderer.SignatureHook; /// /// Starts listening for nameplate updates from the hook service. /// public Task StartAsync(CancellationToken cancellationToken) { _nameplateUpdateHookService.NameplateUpdated += OnNameplateUpdated; return Task.CompletedTask; } /// /// Stops listening for nameplate updates and tears down any constructed nodes. /// public Task StopAsync(CancellationToken cancellationToken) { _nameplateUpdateHookService.NameplateUpdated -= OnNameplateUpdated; UnsubscribeAll(); TryDestroyNameplateNodes(); return Task.CompletedTask; } /// /// Triggered by the sig hook to refresh native nameplate labels. /// private void HandleNameplateUpdate(RaptureAtkModule* raptureAtkModule) { if (_isUpdating) return; _isUpdating = true; try { RefreshRendererState(); if (!IsSignatureMode) return; if (raptureAtkModule == null) return; var namePlateAddon = GetNamePlateAddon(raptureAtkModule); if (namePlateAddon == null) return; if (_clientState.IsGPosing) { HideAllNameplateNodes(namePlateAddon); return; } var fw = Framework.Instance(); if (fw == null) return; var frame = fw->FrameCounter; if (_lastSignatureUpdateFrame == frame) return; _lastSignatureUpdateFrame = frame; UpdateNameplateNodes(namePlateAddon); } finally { _isUpdating = false; } } /// /// Hook callback from the nameplate update signature. /// private void OnNameplateUpdated(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex) { HandleNameplateUpdate(raptureAtkModule); } /// /// Updates the active broadcasting CID set and requests a nameplate redraw. /// 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 (native): {Cids}", string.Join(',', _activeBroadcastingCids)); RequestNameplateRedraw(); } /// /// Sync renderer state with config and clear/remove native nodes if needed. /// private void RefreshRendererState() { var renderer = _configService.Current.LightfinderLabelRenderer; if (renderer == _lastRenderer) return; _lastRenderer = renderer; if (renderer == LightfinderLabelRenderer.SignatureHook) { ClearNameplateCaches(); RequestNameplateRedraw(); } else { TryDestroyNameplateNodes(); ClearNameplateCaches(); } } /// /// Requests a full nameplate update through the native addon. /// private void RequestNameplateRedraw() { if (!IsSignatureMode) return; var raptureAtkModule = GetRaptureAtkModule(); if (raptureAtkModule == null) return; var namePlateAddon = GetNamePlateAddon(raptureAtkModule); if (namePlateAddon == null) return; namePlateAddon->DoFullUpdate = 1; } private HashSet VisibleUserIds => [.. _pairUiService.GetSnapshot().PairsByUid.Values .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) .Select(u => (ulong)u.PlayerCharacterId)]; /// /// Creates/updates LightFinder label nodes for active broadcasts. /// private void UpdateNameplateNodes(AddonNamePlate* namePlateAddon) { if (namePlateAddon == null) { if (_logger.IsEnabled(LogLevel.Debug)) _logger.LogDebug("NamePlate addon unavailable during update, skipping label refresh."); return; } if (!IsNameplateAddonVisible(namePlateAddon)) return; if (!IsSignatureMode) { HideAllNameplateNodes(namePlateAddon); return; } if (_activeBroadcastingCids.Count == 0) { HideAllNameplateNodes(namePlateAddon); 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) return; var config = _configService.Current; var visibleUserIdsSnapshot = VisibleUserIds; var labelColor = UIColors.Get("Lightfinder"); var edgeColor = UIColors.Get("LightfinderEdge"); var scaleMultiplier = Math.Clamp(config.LightfinderLabelScale, 0.5f, 2.0f); var baseScale = config.LightfinderLabelUseIcon ? 1.0f : 0.5f; var effectiveScale = baseScale * scaleMultiplier; var labelContent = config.LightfinderLabelUseIcon ? LightFinderPlateHandler.NormalizeIconGlyph(config.LightfinderLabelIconGlyph) : DefaultLabelText; if (!config.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal))) labelContent = DefaultLabelText; if (!string.Equals(_lastLabelContent, labelContent, StringComparison.Ordinal)) { _lastLabelContent = labelContent; Array.Fill(_lastLabelByIndex, null); } var desiredFontType = config.LightfinderLabelUseIcon ? FontType.Axis : FontType.MiedingerMed; var baseFontSize = config.LightfinderLabelUseIcon ? 36f : 24f; var desiredFontSize = (byte)Math.Clamp((int)Math.Round(baseFontSize * scaleMultiplier), 1, 255); var desiredFlags = config.LightfinderLabelUseIcon ? TextFlags.Edge | TextFlags.Glare | TextFlags.AutoAdjustNodeSize : TextFlags.Edge | TextFlags.Glare; var desiredLineSpacing = (byte)Math.Clamp((int)Math.Round(24 * scaleMultiplier), 0, byte.MaxValue); var defaultNodeWidth = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale); var defaultNodeHeight = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale); var safeCount = Math.Min(ui3DModule->NamePlateObjectInfoCount, vec.Length); var visibleIndices = new bool[AddonNamePlate.NumNamePlateObjects]; 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; var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject); if (cid == null || !_activeBroadcastingCids.Contains(cid)) continue; var local = _objectTable.LocalPlayer; if (!config.LightfinderLabelShowOwn && local != null && objectInfo->GameObject->GetGameObjectId() == local.GameObjectId) continue; var hidePaired = !config.LightfinderLabelShowPaired; var goId = (ulong)gameObject->GetGameObjectId(); if (hidePaired && visibleUserIdsSnapshot.Contains(goId)) continue; var nameplateObject = namePlateAddon->NamePlateObjectArray[nameplateIndex]; var root = nameplateObject.RootComponentNode; var nameContainer = nameplateObject.NameContainer; var nameText = nameplateObject.NameText; var marker = nameplateObject.MarkerIcon; 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; } var nodeId = GetNameplateNodeId(nameplateIndex); var pNode = EnsureNameplateTextNode(nameContainer, root, nodeId, out var nodeCreated); if (pNode == null) continue; bool isVisible = ((marker != null) && marker->AtkResNode.IsVisible()) || (nameContainer->IsVisible() && nameText->AtkResNode.IsVisible()) || config.LightfinderLabelShowHidden; if (!isVisible) continue; if (!pNode->AtkResNode.IsVisible()) pNode->AtkResNode.ToggleVisibility(enable: true); visibleIndices[nameplateIndex] = true; if (nodeCreated) pNode->AtkResNode.SetUseDepthBasedPriority(enable: true); var scaleMatches = NearlyEqual(pNode->AtkResNode.ScaleX, effectiveScale) && NearlyEqual(pNode->AtkResNode.ScaleY, effectiveScale); if (!scaleMatches) pNode->AtkResNode.SetScale(effectiveScale, effectiveScale); var fontTypeChanged = pNode->FontType != desiredFontType; if (fontTypeChanged) pNode->FontType = desiredFontType; var fontSizeChanged = pNode->FontSize != desiredFontSize; if (fontSizeChanged) pNode->FontSize = desiredFontSize; var needsTextUpdate = nodeCreated || !string.Equals(_lastLabelByIndex[nameplateIndex], labelContent, StringComparison.Ordinal); if (needsTextUpdate) { pNode->SetText(labelContent); _lastLabelByIndex[nameplateIndex] = labelContent; } var flagsChanged = pNode->TextFlags != desiredFlags; var nodeWidth = (int)pNode->AtkResNode.GetWidth(); if (nodeWidth <= 0) nodeWidth = defaultNodeWidth; var nodeHeight = defaultNodeHeight; AlignmentType alignment; var textScaleY = nameText->AtkResNode.ScaleY; if (textScaleY <= 0f) textScaleY = 1f; var blockHeight = Math.Abs((int)nameplateObject.TextH); if (blockHeight > 0) { _cachedNameplateTextHeights[nameplateIndex] = blockHeight; } else { blockHeight = _cachedNameplateTextHeights[nameplateIndex]; } if (blockHeight <= 0) { blockHeight = GetScaledTextHeight(nameText); if (blockHeight <= 0) blockHeight = nodeHeight; _cachedNameplateTextHeights[nameplateIndex] = blockHeight; } var containerHeight = (int)nameContainer->Height; if (containerHeight > 0) { _cachedNameplateContainerHeights[nameplateIndex] = containerHeight; } else { containerHeight = _cachedNameplateContainerHeights[nameplateIndex]; } if (containerHeight <= 0) { containerHeight = blockHeight + (int)Math.Round(8 * textScaleY); if (containerHeight <= blockHeight) containerHeight = blockHeight + 1; _cachedNameplateContainerHeights[nameplateIndex] = containerHeight; } var blockTop = containerHeight - blockHeight; if (blockTop < 0) blockTop = 0; var verticalPadding = (int)Math.Round(4 * effectiveScale); var positionY = blockTop - verticalPadding - nodeHeight; var textWidth = Math.Abs((int)nameplateObject.TextW); if (textWidth <= 0) { textWidth = GetScaledTextWidth(nameText); if (textWidth <= 0) textWidth = nodeWidth; } if (textWidth > 0) { _cachedNameplateTextWidths[nameplateIndex] = textWidth; } var textOffset = (int)Math.Round(nameText->AtkResNode.X); var hasValidOffset = false; if (Math.Abs((int)nameplateObject.TextW) > 0 || textOffset != 0) { _cachedNameplateTextOffsets[nameplateIndex] = textOffset; hasValidOffset = true; } else if (_cachedNameplateTextOffsets[nameplateIndex] != int.MinValue) { hasValidOffset = true; } int positionX; if (!config.LightfinderLabelUseIcon) { var needsWidthRefresh = nodeCreated || needsTextUpdate || !scaleMatches || fontTypeChanged || fontSizeChanged || flagsChanged; if (flagsChanged) pNode->TextFlags = desiredFlags; if (needsWidthRefresh) { if (pNode->AtkResNode.Width != 0) pNode->AtkResNode.Width = 0; nodeWidth = (int)pNode->AtkResNode.GetWidth(); if (nodeWidth <= 0) nodeWidth = defaultNodeWidth; } if (pNode->AtkResNode.Width != (ushort)nodeWidth) pNode->AtkResNode.Width = (ushort)nodeWidth; } else { var needsWidthRefresh = nodeCreated || needsTextUpdate || !scaleMatches || fontTypeChanged || fontSizeChanged || flagsChanged; if (flagsChanged) pNode->TextFlags = desiredFlags; if (needsWidthRefresh && pNode->AtkResNode.Width != 0) pNode->AtkResNode.Width = 0; nodeWidth = pNode->AtkResNode.GetWidth(); } if (config.LightfinderAutoAlign && nameContainer != null && hasValidOffset) { var nameplateWidth = (int)nameContainer->Width; int leftPos = nameplateWidth / 8; int rightPos = nameplateWidth - nodeWidth - (nameplateWidth / 8); int centrePos = (nameplateWidth - nodeWidth) / 2; int staticMargin = 24; int calcMargin = (int)(nameplateWidth * 0.08f); switch (config.LabelAlignment) { case LabelAlignment.Left: positionX = config.LightfinderLabelUseIcon ? leftPos + staticMargin : leftPos; alignment = AlignmentType.BottomLeft; break; case LabelAlignment.Right: positionX = config.LightfinderLabelUseIcon ? rightPos - staticMargin : nameplateWidth - nodeWidth + calcMargin; alignment = AlignmentType.BottomRight; break; default: positionX = config.LightfinderLabelUseIcon ? centrePos : centrePos + calcMargin; alignment = AlignmentType.Bottom; break; } } else { positionX = 58 + config.LightfinderLabelOffsetX; alignment = AlignmentType.Bottom; } positionY += config.LightfinderLabelOffsetY; alignment = (AlignmentType)Math.Clamp((int)alignment, 0, 8); if (pNode->AtkResNode.Color.A != 255) pNode->AtkResNode.Color.A = 255; var textR = (byte)(labelColor.X * 255); var textG = (byte)(labelColor.Y * 255); var textB = (byte)(labelColor.Z * 255); var textA = (byte)(labelColor.W * 255); if (pNode->TextColor.R != textR || pNode->TextColor.G != textG || pNode->TextColor.B != textB || pNode->TextColor.A != textA) { pNode->TextColor.R = textR; pNode->TextColor.G = textG; pNode->TextColor.B = textB; pNode->TextColor.A = textA; } var edgeR = (byte)(edgeColor.X * 255); var edgeG = (byte)(edgeColor.Y * 255); var edgeB = (byte)(edgeColor.Z * 255); var edgeA = (byte)(edgeColor.W * 255); if (pNode->EdgeColor.R != edgeR || pNode->EdgeColor.G != edgeG || pNode->EdgeColor.B != edgeB || pNode->EdgeColor.A != edgeA) { pNode->EdgeColor.R = edgeR; pNode->EdgeColor.G = edgeG; pNode->EdgeColor.B = edgeB; pNode->EdgeColor.A = edgeA; } var desiredAlignment = config.LightfinderLabelUseIcon ? alignment : AlignmentType.Bottom; if (pNode->AlignmentType != desiredAlignment) pNode->AlignmentType = desiredAlignment; var desiredX = (short)Math.Clamp(positionX, short.MinValue, short.MaxValue); var desiredY = (short)Math.Clamp(positionY, short.MinValue, short.MaxValue); if (!NearlyEqual(pNode->AtkResNode.X, desiredX) || !NearlyEqual(pNode->AtkResNode.Y, desiredY)) pNode->AtkResNode.SetPositionShort(desiredX, desiredY); if (pNode->LineSpacing != desiredLineSpacing) pNode->LineSpacing = desiredLineSpacing; if (pNode->CharSpacing != 1) pNode->CharSpacing = 1; } HideUnmarkedNodes(namePlateAddon, visibleIndices); } /// /// Resolve the current RaptureAtkModule for native UI access. /// private static RaptureAtkModule* GetRaptureAtkModule() { var framework = Framework.Instance(); if (framework == null) return null; var uiModule = framework->GetUIModule(); if (uiModule == null) return null; return uiModule->GetRaptureAtkModule(); } /// /// Resolve the NamePlate addon from the given RaptureAtkModule. /// private static AddonNamePlate* GetNamePlateAddon(RaptureAtkModule* raptureAtkModule) { if (raptureAtkModule == null) return null; var addon = raptureAtkModule->RaptureAtkUnitManager.GetAddonByName("NamePlate"); return addon != null ? (AddonNamePlate*)addon : null; } private static uint GetNameplateNodeId(int index) => NameplateNodeIdBase + (uint)index; /// /// Checks if the NamePlate addon is visible and safe to touch. /// private static bool IsNameplateAddonVisible(AddonNamePlate* namePlateAddon) { if (namePlateAddon == null) return false; var root = namePlateAddon->AtkUnitBase.RootNode; return root != null && root->IsVisible(); } /// /// Finds a LightFinder text node by ID in the name container. /// private static AtkTextNode* FindNameplateTextNode(AtkResNode* nameContainer, uint nodeId) { if (nameContainer == null) return null; var child = nameContainer->ChildNode; while (child != null) { if (child->NodeId == nodeId && child->Type == NodeType.Text && child->ParentNode == nameContainer) return (AtkTextNode*)child; child = child->PrevSiblingNode; } return null; } /// /// Ensures a LightFinder text node exists for the given nameplate index. /// private static AtkTextNode* EnsureNameplateTextNode(AtkResNode* nameContainer, AtkComponentNode* root, uint nodeId, out bool created) { created = false; if (nameContainer == null || root == null || root->Component == null) return null; var existing = FindNameplateTextNode(nameContainer, nodeId); if (existing != null) return existing; if (nameContainer->ChildNode == null) return null; var newNode = AtkNodeHelpers.CreateOrphanTextNode(nodeId, TextFlags.Edge | TextFlags.Glare); if (newNode == null) return null; var lastChild = nameContainer->ChildNode; while (lastChild->PrevSiblingNode != null) lastChild = lastChild->PrevSiblingNode; newNode->AtkResNode.NextSiblingNode = lastChild; newNode->AtkResNode.ParentNode = nameContainer; lastChild->PrevSiblingNode = (AtkResNode*)newNode; root->Component->UldManager.UpdateDrawNodeList(); newNode->AtkResNode.SetUseDepthBasedPriority(true); created = true; return newNode; } /// /// Hides all native LightFinder nodes on the nameplate addon. /// private static void HideAllNameplateNodes(AddonNamePlate* namePlateAddon) { if (namePlateAddon == null) return; if (!IsNameplateAddonVisible(namePlateAddon)) return; for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i) { HideNameplateTextNode(namePlateAddon->NamePlateObjectArray[i], GetNameplateNodeId(i)); } } /// /// Hides all LightFinder nodes not marked as visible this frame. /// private static void HideUnmarkedNodes(AddonNamePlate* namePlateAddon, bool[] visibleIndices) { if (namePlateAddon == null) return; if (!IsNameplateAddonVisible(namePlateAddon)) return; var visibleLength = visibleIndices.Length; for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i) { if (i < visibleLength && visibleIndices[i]) continue; HideNameplateTextNode(namePlateAddon->NamePlateObjectArray[i], GetNameplateNodeId(i)); } } /// /// Hides the LightFinder text node for a single nameplate object. /// private static void HideNameplateTextNode(AddonNamePlate.NamePlateObject nameplateObject, uint nodeId) { var nameContainer = nameplateObject.NameContainer; if (nameContainer == null) return; var node = FindNameplateTextNode(nameContainer, nodeId); if (!IsValidNameplateTextNode(node, nameContainer)) return; node->AtkResNode.ToggleVisibility(false); } /// /// Attempts to destroy all constructed LightFinder nodes safely. /// private void TryDestroyNameplateNodes() { var raptureAtkModule = GetRaptureAtkModule(); if (raptureAtkModule == null) { if (_logger.IsEnabled(LogLevel.Debug)) _logger.LogDebug("Unable to destroy nameplate nodes because the RaptureAtkModule is not available."); return; } var namePlateAddon = GetNamePlateAddon(raptureAtkModule); if (namePlateAddon == null) { if (_logger.IsEnabled(LogLevel.Debug)) _logger.LogDebug("Unable to destroy nameplate nodes because the NamePlate addon is not available."); return; } DestroyNameplateNodes(namePlateAddon); } /// /// Removes all constructed LightFinder nodes from the given nameplate addon. /// private void DestroyNameplateNodes(AddonNamePlate* namePlateAddon) { if (namePlateAddon == null) return; for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i) { var nameplateObject = namePlateAddon->NamePlateObjectArray[i]; var root = nameplateObject.RootComponentNode; var nameContainer = nameplateObject.NameContainer; if (root == null || root->Component == null || nameContainer == null) continue; var nodeId = GetNameplateNodeId(i); var textNode = FindNameplateTextNode(nameContainer, nodeId); if (!IsValidNameplateTextNode(textNode, nameContainer)) continue; try { var resNode = &textNode->AtkResNode; if (resNode->PrevSiblingNode != null) resNode->PrevSiblingNode->NextSiblingNode = resNode->NextSiblingNode; if (resNode->NextSiblingNode != null) resNode->NextSiblingNode->PrevSiblingNode = resNode->PrevSiblingNode; root->Component->UldManager.UpdateDrawNodeList(); resNode->Destroy(true); } catch (Exception e) { _logger.LogError(e, "Unknown error while removing text node 0x{Node:X} for nameplate {Index} on component node 0x{Component:X}", (IntPtr)textNode, i, (IntPtr)root); } } ClearNameplateCaches(); } /// /// Validates that a node is a LightFinder text node owned by the container. /// private static bool IsValidNameplateTextNode(AtkTextNode* node, AtkResNode* nameContainer) { if (node == null || nameContainer == null) return false; var resNode = &node->AtkResNode; return resNode->Type == NodeType.Text && resNode->ParentNode == nameContainer; } /// /// Float comparison helper for UI values. /// private static bool NearlyEqual(float a, float b, float epsilon = 0.001f) => Math.Abs(a - b) <= epsilon; private static 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 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); } /// /// Clears cached text sizing and label state for nameplates. /// public void ClearNameplateCaches() { Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length); Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length); Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length); Array.Fill(_cachedNameplateTextOffsets, int.MinValue); Array.Fill(_lastLabelByIndex, null); } }