using Dalamud.Game.Addon.Lifecycle; using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; using Dalamud.Game.Text; 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.PlayerData.Pairs; using LightlessSync.Services.Mediator; using LightlessSync.UI; using LightlessSync.Utils; using LightlessSync.UtilsEnum.Enum; // Created using https://github.com/PunishedPineapple/Distance as a reference, thank you! using Microsoft.Extensions.Logging; using System.Collections.Immutable; using System.Globalization; namespace LightlessSync.Services; public unsafe class NameplateHandler : IMediatorSubscriber { private readonly ILogger _logger; private readonly IAddonLifecycle _addonLifecycle; private readonly IGameGui _gameGui; private readonly IClientState _clientState; private readonly DalamudUtilService _dalamudUtil; private readonly LightlessConfigService _configService; private readonly PairManager _pairManager; private readonly LightlessMediator _mediator; public LightlessMediator Mediator => _mediator; private bool _mEnabled = false; private bool _needsLabelRefresh = false; private AddonNamePlate* _mpNameplateAddon = null; private readonly AtkTextNode*[] _mTextNodes = new AtkTextNode*[AddonNamePlate.NumNamePlateObjects]; 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]; internal const uint mNameplateNodeIDBase = 0x7D99D500; private const string DefaultLabelText = "LightFinder"; private const SeIconChar DefaultIcon = SeIconChar.Hyadelyn; private const int _containerOffsetX = 50; private static readonly string DefaultIconGlyph = SeIconCharExtensions.ToIconString(DefaultIcon); private ImmutableHashSet _activeBroadcastingCids = []; public NameplateHandler(ILogger logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, DalamudUtilService dalamudUtil, LightlessConfigService configService, LightlessMediator mediator, IClientState clientState, PairManager pairManager) { _logger = logger; _addonLifecycle = addonLifecycle; _gameGui = gameGui; _dalamudUtil = dalamudUtil; _configService = configService; _mediator = mediator; _clientState = clientState; _pairManager = pairManager; System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue); } internal void Init() { EnableNameplate(); _mediator.Subscribe(this, OnTick); } internal void Uninit() { DisableNameplate(); DestroyNameplateNodes(); _mediator.Unsubscribe(this); _mpNameplateAddon = null; } internal void EnableNameplate() { if (!_mEnabled) { try { _addonLifecycle.RegisterListener(AddonEvent.PostDraw, "NamePlate", NameplateDrawDetour); _mEnabled = true; } catch (Exception e) { _logger.LogError($"Unknown error while trying to enable nameplate distances:\n{e}"); DisableNameplate(); } } } internal void DisableNameplate() { if (_mEnabled) { try { _addonLifecycle.UnregisterListener(NameplateDrawDetour); } catch (Exception e) { _logger.LogError($"Unknown error while unregistering nameplate listener:\n{e}"); } _mEnabled = false; HideAllNameplateNodes(); } } private void NameplateDrawDetour(AddonEvent type, AddonArgs args) { var pNameplateAddon = (AddonNamePlate*)args.Addon.Address; if (_mpNameplateAddon != pNameplateAddon) { for (int i = 0; i < _mTextNodes.Length; ++i) _mTextNodes[i] = null; System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length); System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length); System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length); System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue); _mpNameplateAddon = pNameplateAddon; if (_mpNameplateAddon != null) CreateNameplateNodes(); } UpdateNameplateNodes(); } private void CreateNameplateNodes() { for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i) { var nameplateObject = GetNameplateObject(i); if (nameplateObject == null) continue; var pNameplateResNode = nameplateObject.Value.NameContainer; if (pNameplateResNode == null) continue; if (pNameplateResNode->ChildNode == null) continue; var pNewNode = AtkNodeHelpers.CreateOrphanTextNode(mNameplateNodeIDBase + (uint)i, TextFlags.Edge | TextFlags.Glare); if (pNewNode != null) { var pLastChild = pNameplateResNode->ChildNode; while (pLastChild->PrevSiblingNode != null) pLastChild = pLastChild->PrevSiblingNode; pNewNode->AtkResNode.NextSiblingNode = pLastChild; pNewNode->AtkResNode.ParentNode = pNameplateResNode; pLastChild->PrevSiblingNode = (AtkResNode*)pNewNode; nameplateObject.Value.RootComponentNode->Component->UldManager.UpdateDrawNodeList(); pNewNode->AtkResNode.SetUseDepthBasedPriority(true); _mTextNodes[i] = pNewNode; } } } private void DestroyNameplateNodes() { var pCurrentNameplateAddon = (AddonNamePlate*)_gameGui.GetAddonByName("NamePlate", 1).Address; if (_mpNameplateAddon == null || _mpNameplateAddon != pCurrentNameplateAddon) return; for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i) { var pTextNode = _mTextNodes[i]; var pNameplateNode = GetNameplateComponentNode(i); if (pTextNode != null && pNameplateNode != null) { try { if (pTextNode->AtkResNode.PrevSiblingNode != null) pTextNode->AtkResNode.PrevSiblingNode->NextSiblingNode = pTextNode->AtkResNode.NextSiblingNode; if (pTextNode->AtkResNode.NextSiblingNode != null) pTextNode->AtkResNode.NextSiblingNode->PrevSiblingNode = pTextNode->AtkResNode.PrevSiblingNode; pNameplateNode->Component->UldManager.UpdateDrawNodeList(); pTextNode->AtkResNode.Destroy(true); _mTextNodes[i] = null; } catch (Exception e) { _logger.LogError($"Unknown error while removing text node 0x{(IntPtr)pTextNode:X} for nameplate {i} on component node 0x{(IntPtr)pNameplateNode:X}:\n{e}"); } } } System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length); System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length); System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length); System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue); } private void HideAllNameplateNodes() { for (int i = 0; i < _mTextNodes.Length; ++i) { HideNameplateTextNode(i); } } private void UpdateNameplateNodes() { var currentAddon = (AddonNamePlate*)_gameGui.GetAddonByName("NamePlate").Address; if (_mpNameplateAddon == null || currentAddon == null || currentAddon != _mpNameplateAddon) return; var framework = Framework.Instance(); var ui3DModule = framework->GetUIModule()->GetUI3DModule(); if (ui3DModule == null) return; var vec = ui3DModule->NamePlateObjectInfoPointers; if (vec.IsEmpty) return; var safeCount = System.Math.Min( ui3DModule->NamePlateObjectInfoCount, vec.Length ); for (int i = 0; i < safeCount; ++i) { var config = _configService.Current; 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 pNode = _mTextNodes[nameplateIndex]; if (pNode == null) continue; // CID gating var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)objectInfo->GameObject); if (cid == null || !_activeBroadcastingCids.Contains(cid)) { pNode->AtkResNode.ToggleVisibility(enable: false); continue; } var local = _clientState.LocalPlayer; if (!config.LightfinderLabelShowOwn && local != null && objectInfo->GameObject->GetGameObjectId() == local.GameObjectId) { pNode->AtkResNode.ToggleVisibility(enable: false); continue; } var visibleUserIds = VisibleUserIds; var hidePaired = !config.LightfinderLabelShowPaired; var goId = (ulong)objectInfo->GameObject->GetGameObjectId(); if (hidePaired && visibleUserIds.Contains(goId)) { pNode->AtkResNode.ToggleVisibility(enable: false); continue; } var nameplateObject = _mpNameplateAddon->NamePlateObjectArray[nameplateIndex]; var root = nameplateObject.RootComponentNode; var nameContainer = nameplateObject.NameContainer; var nameText = nameplateObject.NameText; var marker = nameplateObject.MarkerIcon; if (root == null || nameContainer == null || nameText == null) { pNode->AtkResNode.ToggleVisibility(enable: false); continue; } root->Component->UldManager.UpdateDrawNodeList(); bool isVisible = ((marker != null) && marker->AtkResNode.IsVisible()) || (nameContainer->IsVisible() && nameText->AtkResNode.IsVisible()) || config.LightfinderLabelShowHidden; pNode->AtkResNode.ToggleVisibility(isVisible); if (!isVisible) continue; var labelColor = UIColors.Get("Lightfinder"); var edgeColor = UIColors.Get("LightfinderEdge"); var scaleMultiplier = System.Math.Clamp(config.LightfinderLabelScale, 0.5f, 2.0f); var baseScale = config.LightfinderLabelUseIcon ? 1.0f : 0.5f; var effectiveScale = baseScale * scaleMultiplier; var labelContent = config.LightfinderLabelUseIcon ? NormalizeIconGlyph(config.LightfinderLabelIconGlyph) : DefaultLabelText; pNode->FontType = config.LightfinderLabelUseIcon ? FontType.Axis : FontType.MiedingerMed; pNode->AtkResNode.SetScale(effectiveScale, effectiveScale); var nodeWidth = (int)pNode->AtkResNode.GetWidth(); if (nodeWidth <= 0) nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale); var nodeHeight = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale); var baseFontSize = config.LightfinderLabelUseIcon ? 36f : 24f; var computedFontSize = (int)System.Math.Round(baseFontSize * scaleMultiplier); pNode->FontSize = (byte)System.Math.Clamp(computedFontSize, 1, 255); AlignmentType alignment; var textScaleY = nameText->AtkResNode.ScaleY; if (textScaleY <= 0f) textScaleY = 1f; var blockHeight = System.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)System.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)System.Math.Round(4 * effectiveScale); var positionY = blockTop - verticalPadding - nodeHeight; var textWidth = System.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)System.Math.Round(nameText->AtkResNode.X); var hasValidOffset = true; if (System.Math.Abs((int)nameplateObject.TextW) > 0 || textOffset != 0) { _cachedNameplateTextOffsets[nameplateIndex] = textOffset; } else if (_cachedNameplateTextOffsets[nameplateIndex] != int.MinValue) { textOffset = _cachedNameplateTextOffsets[nameplateIndex]; } else { hasValidOffset = false; } int positionX; if (!config.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal))) labelContent = DefaultLabelText; pNode->FontType = config.LightfinderLabelUseIcon ? FontType.Axis : FontType.MiedingerMed; pNode->SetText(labelContent); if (!config.LightfinderLabelUseIcon) { pNode->TextFlags &= ~TextFlags.AutoAdjustNodeSize; pNode->AtkResNode.Width = 0; nodeWidth = (int)pNode->AtkResNode.GetWidth(); if (nodeWidth <= 0) nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale); pNode->AtkResNode.Width = (ushort)nodeWidth; } else { pNode->TextFlags |= TextFlags.AutoAdjustNodeSize; 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)System.Math.Clamp((int)alignment, 0, 8); pNode->AtkResNode.SetUseDepthBasedPriority(enable: true); pNode->AtkResNode.Color.A = 255; pNode->TextColor.R = (byte)(labelColor.X * 255); pNode->TextColor.G = (byte)(labelColor.Y * 255); pNode->TextColor.B = (byte)(labelColor.Z * 255); pNode->TextColor.A = (byte)(labelColor.W * 255); pNode->EdgeColor.R = (byte)(edgeColor.X * 255); pNode->EdgeColor.G = (byte)(edgeColor.Y * 255); pNode->EdgeColor.B = (byte)(edgeColor.Z * 255); pNode->EdgeColor.A = (byte)(edgeColor.W * 255); if(!config.LightfinderLabelUseIcon) { pNode->AlignmentType = AlignmentType.Bottom; } else { pNode->AlignmentType = alignment; } pNode->AtkResNode.SetPositionShort( (short)System.Math.Clamp(positionX, short.MinValue, short.MaxValue), (short)System.Math.Clamp(positionY, short.MinValue, short.MaxValue) ); var computedLineSpacing = (int)System.Math.Round(24 * scaleMultiplier); pNode->LineSpacing = (byte)System.Math.Clamp(computedLineSpacing, 0, byte.MaxValue); pNode->CharSpacing = 1; pNode->TextFlags = config.LightfinderLabelUseIcon ? TextFlags.Edge | TextFlags.Glare | TextFlags.AutoAdjustNodeSize : TextFlags.Edge | TextFlags.Glare; } } 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)System.Math.Round(rawWidth * scale); return System.Math.Max(1, computed); } 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; } 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 void HideNameplateTextNode(int i) { var pNode = _mTextNodes[i]; if (pNode != null) { pNode->AtkResNode.ToggleVisibility(false); } } private AddonNamePlate.NamePlateObject? GetNameplateObject(int i) { if (i < AddonNamePlate.NumNamePlateObjects && _mpNameplateAddon != null && _mpNameplateAddon->NamePlateObjectArray[i].RootComponentNode != null) { return _mpNameplateAddon->NamePlateObjectArray[i]; } else { return null; } } private AtkComponentNode* GetNameplateComponentNode(int i) { var nameplateObject = GetNameplateObject(i); return nameplateObject != null ? nameplateObject.Value.RootComponentNode : null; } private HashSet VisibleUserIds => [.. _pairManager.GetOnlineUserPairs() .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; } } public void UpdateBroadcastingCids(IEnumerable cids) { var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal); if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet)) return; _activeBroadcastingCids = newSet; _logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(',', _activeBroadcastingCids)); FlagRefresh(); } public void ClearNameplateCaches() { System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length); System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length); System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length); System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue); } }