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.LightlessConfiguration.Configurations; 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.Globalization; using System.Text; 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.LinkMarker; private static readonly string DefaultIconGlyph = SeIconCharExtensions.ToIconString(DefaultIcon); private volatile HashSet _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; 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 framework = Framework.Instance(); var ui3DModule = framework->GetUIModule()->GetUI3DModule(); if (ui3DModule == null) return; for (int i = 0; i < ui3DModule->NamePlateObjectInfoCount; ++i) { var objectInfo = ui3DModule->NamePlateObjectInfoPointers[i].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; var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)objectInfo->GameObject); if (cid == null || !_activeBroadcastingCids.Contains(cid)) { pNode->AtkResNode.ToggleVisibility(false); continue; } if (!_configService.Current.LightfinderLabelShowOwn && (objectInfo->GameObject->GetGameObjectId() == _clientState.LocalPlayer.GameObjectId)) { pNode->AtkResNode.ToggleVisibility(false); continue; } if (!_configService.Current.LightfinderLabelShowPaired && VisibleUserIds.Any(u => u == objectInfo->GameObject->GetGameObjectId())) { pNode->AtkResNode.ToggleVisibility(false); continue; } var nameplateObject = mpNameplateAddon->NamePlateObjectArray[nameplateIndex]; nameplateObject.RootComponentNode->Component->UldManager.UpdateDrawNodeList(); var pNameplateIconNode = nameplateObject.MarkerIcon; var pNameplateResNode = nameplateObject.NameContainer; var pNameplateTextNode = nameplateObject.NameText; bool IsVisible = pNameplateIconNode->AtkResNode.IsVisible() || (pNameplateResNode->IsVisible() && pNameplateTextNode->AtkResNode.IsVisible()); pNode->AtkResNode.ToggleVisibility(IsVisible); var nameContainer = nameplateObject.NameContainer; var nameText = nameplateObject.NameText; if (nameContainer == null || nameText == null) { pNode->AtkResNode.ToggleVisibility(false); continue; } var labelColor = UIColors.Get("LightlessPurple"); var edgeColor = UIColors.Get("FullBlack"); var config = _configService.Current; var scaleMultiplier = System.Math.Clamp(config.LightfinderLabelScale, 0.5f, 2.0f); var baseScale = config.LightfinderLabelUseIcon ? 1.0f : 0.5f; var effectiveScale = baseScale * scaleMultiplier; var nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale); var nodeHeight = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale); int positionX = 58; AlignmentType alignment = AlignmentType.Bottom; 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) { _cachedNameplateTextWidths[nameplateIndex] = textWidth; } else { textWidth = _cachedNameplateTextWidths[nameplateIndex]; } if (textWidth <= 0) { textWidth = GetScaledTextWidth(nameText); if (textWidth <= 0) textWidth = nodeWidth; _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; } if (config.LightfinderAutoAlign && nameContainer != null && hasValidOffset) { switch (config.LabelAlignment) { case LabelAlignment.Left: positionX = textOffset; alignment = AlignmentType.BottomLeft; break; case LabelAlignment.Right: positionX = textOffset + textWidth - nodeWidth; alignment = AlignmentType.BottomRight; break; default: positionX = textOffset + textWidth / 2 - nodeWidth / 2; alignment = AlignmentType.Bottom; break; } } else { alignment = AlignmentType.Bottom; } positionX += config.LightfinderLabelOffsetX; positionY += config.LightfinderLabelOffsetY; alignment = (AlignmentType)System.Math.Clamp((int)alignment, 0, 8); pNode->AtkResNode.SetPositionShort((short)System.Math.Clamp(positionX, short.MinValue, short.MaxValue), (short)System.Math.Clamp(positionY, short.MinValue, short.MaxValue)); pNode->AtkResNode.SetUseDepthBasedPriority(true); pNode->AtkResNode.SetScale(effectiveScale, effectiveScale); 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); var baseFontSize = config.LightfinderLabelUseIcon ? 36f : 24f; var computedFontSize = (int)System.Math.Round(baseFontSize * scaleMultiplier); pNode->FontSize = (byte)System.Math.Clamp(computedFontSize, 1, 255); pNode->AlignmentType = alignment; 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; var labelContent = config.LightfinderLabelUseIcon ? NormalizeIconGlyph(config.LightfinderLabelIconGlyph) : DefaultLabelText; pNode->FontType = config.LightfinderLabelUseIcon ? FontType.Axis : FontType.MiedingerMed; pNode->SetText(labelContent); } } 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.ToHashSet(); var changed = !_activeBroadcastingCids.SetEquals(newSet); if (!changed) return; _activeBroadcastingCids.Clear(); foreach (var cid in newSet) _activeBroadcastingCids.Add(cid); _logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(",", _activeBroadcastingCids)); FlagRefresh(); } }