using Dalamud.Game.ClientState.Objects.Enums; using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.NativeWrapper; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Hooking; using Dalamud.Plugin.Services; using Dalamud.Utility; using Dalamud.Utility.Signatures; 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.Services; using Microsoft.Extensions.Logging; using System.Numerics; using static LightlessSync.UI.DtrEntry; using LSeStringBuilder = Lumina.Text.SeStringBuilder; namespace LightlessSync.Services; /// /// NameplateService is used for coloring our nameplates based on the settings of the user. /// public unsafe class NameplateService : DisposableMediatorSubscriberBase { private delegate nint UpdateNameplateDelegate(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex); // Glyceri, Thanks :bow: [Signature("40 53 55 57 41 56 48 81 EC ?? ?? ?? ?? 48 8B 84 24", DetourName = nameof(UpdateNameplateDetour))] private readonly Hook? _nameplateHook = null; private readonly ILogger _logger; private readonly LightlessConfigService _configService; private readonly IClientState _clientState; private readonly IGameGui _gameGui; private readonly IObjectTable _objectTable; private readonly PairUiService _pairUiService; public NameplateService(ILogger logger, LightlessConfigService configService, IClientState clientState, IGameGui gameGui, IObjectTable objectTable, IGameInteropProvider interop, LightlessMediator lightlessMediator, PairUiService pairUiService) : base(logger, lightlessMediator) { _logger = logger; _configService = configService; _clientState = clientState; _gameGui = gameGui; _objectTable = objectTable; _pairUiService = pairUiService; interop.InitializeFromAttributes(this); _nameplateHook?.Enable(); Refresh(); Mediator.Subscribe(this, (_) => Refresh()); } /// /// Detour for the game's internal nameplate update function. /// This will be called whenever the client updates any nameplate. /// /// We hook into it to apply our own nameplate coloring logic via , /// private nint UpdateNameplateDetour(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex) { try { SetNameplate(namePlateInfo, battleChara); } catch (Exception e) { _logger.LogError(e, "Error in NameplateService UpdateNameplateDetour"); } return _nameplateHook!.Original(raptureAtkModule, namePlateInfo, numArray, stringArray, battleChara, numArrayIndex, stringArrayIndex); } /// /// Determine if the player should be colored based on conditions (isFriend, IsInParty) /// /// Player character that will be checked /// All visible users in the current object table /// PLayer should or shouldnt be colored based on the result. True means colored private bool ShouldColorPlayer(IPlayerCharacter playerCharacter, HashSet visibleUserIds) { if (!visibleUserIds.Contains(playerCharacter.GameObjectId)) return false; var isInParty = playerCharacter.StatusFlags.HasFlag(StatusFlags.PartyMember); var isFriend = playerCharacter.StatusFlags.HasFlag(StatusFlags.Friend); bool partyColorAllowed = _configService.Current.overridePartyColor && isInParty; bool friendColorAllowed = _configService.Current.overrideFriendColor && isFriend; if ((isInParty && !partyColorAllowed) || (isFriend && !friendColorAllowed)) return false; return true; } /// /// Setting up the nameplate of the user to be colored /// /// Information given from the Signature to be updated /// Character from FF private void SetNameplate(RaptureAtkModule.NamePlateInfo* namePlateInfo, BattleChara* battleChara) { if (!_configService.Current.IsNameplateColorsEnabled || _clientState.IsPvPExcludingDen) return; if (namePlateInfo == null || battleChara == null) return; var obj = _objectTable.FirstOrDefault(o => o.Address == (nint)battleChara); if (obj is not IPlayerCharacter player) return; var snapshot = _pairUiService.GetSnapshot(); var visibleUsersIds = snapshot.PairsByUid.Values .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) .Select(u => (ulong)u.PlayerCharacterId) .ToHashSet(); //Check if player should be colored if (!ShouldColorPlayer(player, visibleUsersIds)) return; var originalName = player.Name.ToString(); //Check if not null of the name if (string.IsNullOrEmpty(originalName)) return; //Check if any characters/symbols are forbidden if (HasForbiddenSeStringChars(originalName)) return; //Swap color channels as we store them in BGR format as FF loves that var cfgColors = SwapColorChannels(_configService.Current.NameplateColors); var coloredName = WrapStringInColor(originalName, cfgColors.Glow, cfgColors.Foreground); //Replace string of nameplate with our colored one namePlateInfo->Name.SetString(coloredName.EncodeWithNullTerminator()); } /// /// Converts Uint code to Vector4 as we store Colors in Uint in our config, needed for lumina /// /// Color code /// Vector4 Color private static Vector4 RgbUintToVector4(uint rgb) { float r = ((rgb >> 16) & 0xFF) / 255f; float g = ((rgb >> 8) & 0xFF) / 255f; float b = (rgb & 0xFF) / 255f; return new Vector4(r, g, b, 1f); } /// /// Checks if the string has any forbidden characters/symbols as the string builder wouldnt append. /// /// String that has to be checked /// Contains forbidden characters/symbols or not private static bool HasForbiddenSeStringChars(string s) { if (string.IsNullOrEmpty(s)) return false; foreach (var ch in s) { if (ch == '\0' || ch == '\u0002') return true; } return false; } /// /// Wraps the given string with the given edge and text color. /// /// String that has to be wrapped /// Edge(border) color /// Text color /// Color wrapped SeString public static SeString WrapStringInColor(string text, uint? edgeColor = null, uint? textColor = null) { if (string.IsNullOrEmpty(text)) return SeString.Empty; var builder = new LSeStringBuilder(); if (textColor is uint tc) builder.PushColorRgba(RgbUintToVector4(tc)); if (edgeColor is uint ec) builder.PushEdgeColorRgba(RgbUintToVector4(ec)); builder.Append(text); if (edgeColor != null) builder.PopEdgeColor(); if (textColor != null) builder.PopColor(); return builder.ToReadOnlySeString().ToDalamudString(); } /// /// Request redraw of nameplates /// public void RequestRedraw() { Refresh(); } /// /// Toggles the refresh of the Nameplate addon /// protected void Refresh() { AtkUnitBasePtr namePlateAddon = _gameGui.GetAddonByName("NamePlate"); if (namePlateAddon.IsNull) { _logger.LogInformation("NamePlate addon is null, cannot refresh nameplates."); return; } var addonNamePlate = (AddonNamePlate*)namePlateAddon.Address; if (addonNamePlate == null) { _logger.LogInformation("addonNamePlate addon is null, cannot refresh nameplates."); return; } addonNamePlate->DoFullUpdate = 1; } protected override void Dispose(bool disposing) { if (disposing) { _nameplateHook?.Dispose(); } base.Dispose(disposing); } }