lightfinder!

This commit is contained in:
2025-09-24 05:53:22 +09:00
parent 5dce1977c7
commit 9eb2309018
25 changed files with 2497 additions and 26 deletions

View File

@@ -0,0 +1,375 @@
using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.Mediator;
using LightlessSync.Utils;
using LightlessSync.WebAPI;
using LightlessSync.WebAPI.SignalR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Services;
public class BroadcastService : IHostedService, IMediatorSubscriber
{
private readonly ILogger<BroadcastService> _logger;
private readonly ApiController _apiController;
private readonly LightlessMediator _mediator;
private readonly HubFactory _hubFactory;
private readonly LightlessConfigService _config;
private readonly DalamudUtilService _dalamudUtil;
public LightlessMediator Mediator => _mediator;
public bool IsLightFinderAvailable { get; private set; } = true;
public bool IsBroadcasting => _config.Current.BroadcastEnabled;
private bool _syncedOnStartup = false;
private bool _waitingForTtlFetch = false;
private TimeSpan? _remainingTtl = null;
private DateTime _lastTtlCheck = DateTime.MinValue;
private DateTime _lastForcedDisableTime = DateTime.MinValue;
private static readonly TimeSpan DisableCooldown = TimeSpan.FromSeconds(5);
public TimeSpan? RemainingTtl => _remainingTtl;
public TimeSpan? RemainingCooldown
{
get
{
var elapsed = DateTime.UtcNow - _lastForcedDisableTime;
if (elapsed >= DisableCooldown) return null;
return DisableCooldown - elapsed;
}
}
public BroadcastService(ILogger<BroadcastService> logger, LightlessMediator mediator, HubFactory hubFactory, LightlessConfigService config, DalamudUtilService dalamudUtil, ApiController apiController)
{
_logger = logger;
_mediator = mediator;
_hubFactory = hubFactory;
_config = config;
_dalamudUtil = dalamudUtil;
_apiController = apiController;
}
private async Task RequireConnectionAsync(string context, Func<Task> action)
{
if (!_apiController.IsConnected)
{
_logger.LogDebug($"{context} skipped, not connected");
return;
}
await action().ConfigureAwait(false);
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_mediator.Subscribe<EnableBroadcastMessage>(this, OnEnableBroadcast);
_mediator.Subscribe<BroadcastStatusChangedMessage>(this, OnBroadcastStatusChanged);
_mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, OnTick);
_apiController.OnConnected += () => _ = CheckLightfinderSupportAsync(cancellationToken);
_ = CheckLightfinderSupportAsync(cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken)
{
_mediator.UnsubscribeAll(this);
_apiController.OnConnected -= () => _ = CheckLightfinderSupportAsync(cancellationToken);
return Task.CompletedTask;
}
// need to rework this, this is cooked
private async Task CheckLightfinderSupportAsync(CancellationToken cancellationToken)
{
try
{
while (!_apiController.IsConnected && !cancellationToken.IsCancellationRequested)
await Task.Delay(250, cancellationToken).ConfigureAwait(false);
if (cancellationToken.IsCancellationRequested)
return;
var hub = _hubFactory.GetOrCreate(CancellationToken.None);
var dummy = "0".PadLeft(64, '0');
await hub.InvokeAsync<BroadcastStatusInfoDto?>("IsUserBroadcasting", dummy, cancellationToken);
await hub.InvokeAsync("SetBroadcastStatus", dummy, true, null, cancellationToken);
await hub.InvokeAsync<TimeSpan?>("GetBroadcastTtl", dummy, cancellationToken);
await hub.InvokeAsync<Dictionary<string, BroadcastStatusInfoDto?>>("AreUsersBroadcasting", new[] { dummy }, cancellationToken);
IsLightFinderAvailable = true;
_logger.LogInformation("Lightfinder is available.");
}
catch (HubException ex) when (ex.Message.Contains("Method does not exist"))
{
_logger.LogWarning("Lightfinder unavailable: required method missing.");
IsLightFinderAvailable = false;
_config.Current.BroadcastEnabled = false;
_config.Current.BroadcastTtl = DateTime.MinValue;
_config.Save();
_mediator.Publish(new BroadcastStatusChangedMessage(false, null));
}
catch (OperationCanceledException)
{
_logger.LogInformation("Lightfinder check was canceled.");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Lightfinder check failed.");
IsLightFinderAvailable = false;
_config.Current.BroadcastEnabled = false;
_config.Current.BroadcastTtl = DateTime.MinValue;
_config.Save();
_mediator.Publish(new BroadcastStatusChangedMessage(false, null));
}
}
private void OnEnableBroadcast(EnableBroadcastMessage msg)
{
_ = RequireConnectionAsync(nameof(OnEnableBroadcast), async () =>
{
try
{
GroupBroadcastRequestDto? groupDto = null;
if (_config.Current.SyncshellFinderEnabled && _config.Current.SelectedFinderSyncshell != null)
{
groupDto = new GroupBroadcastRequestDto
{
HashedCID = msg.HashedCid,
GID = _config.Current.SelectedFinderSyncshell,
Enabled = msg.Enabled,
};
}
_ = _apiController.SetBroadcastStatus(msg.HashedCid, msg.Enabled, groupDto).ConfigureAwait(false);
_logger.LogInformation("Broadcast {Status} for {Cid}", msg.Enabled ? "enabled" : "disabled", msg.HashedCid);
if (!msg.Enabled)
{
_config.Current.BroadcastEnabled = false;
_config.Current.BroadcastTtl = DateTime.MinValue;
_config.Save();
_mediator.Publish(new BroadcastStatusChangedMessage(false, null));
return;
}
_waitingForTtlFetch = true;
var ttl = await GetBroadcastTtlAsync(msg.HashedCid).ConfigureAwait(false);
if (ttl is { } remaining && remaining > TimeSpan.Zero)
{
_config.Current.BroadcastTtl = DateTime.UtcNow + remaining;
_config.Current.BroadcastEnabled = true;
_config.Save();
_logger.LogInformation("Fetched TTL from server: {TTL}", remaining);
_mediator.Publish(new BroadcastStatusChangedMessage(true, remaining));
}
else
{
_logger.LogWarning("No valid TTL returned after enabling broadcast. Disabling.");
_config.Current.BroadcastEnabled = false;
_config.Current.BroadcastTtl = DateTime.MinValue;
_config.Save();
_mediator.Publish(new BroadcastStatusChangedMessage(false, null));
}
_waitingForTtlFetch = false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to toggle broadcast for {Cid}", msg.HashedCid);
_waitingForTtlFetch = false;
}
});
}
private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg)
{
_config.Current.BroadcastEnabled = msg.Enabled;
_config.Save();
}
public async Task<bool> CheckIfBroadcastingAsync(string targetCid)
{
bool result = false;
await RequireConnectionAsync(nameof(CheckIfBroadcastingAsync), async () =>
{
try
{
_logger.LogInformation("[BroadcastCheck] Checking CID: {cid}", targetCid);
var info = await _apiController.IsUserBroadcasting(targetCid).ConfigureAwait(false);
result = info?.TTL > TimeSpan.Zero;
_logger.LogInformation("[BroadcastCheck] Result for {cid}: {result} (TTL: {ttl}, GID: {gid})", targetCid, result, info?.TTL, info?.GID);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to check broadcast status for {cid}", targetCid);
}
}).ConfigureAwait(false);
return result;
}
public async Task<TimeSpan?> GetBroadcastTtlAsync(string cid)
{
TimeSpan? ttl = null;
await RequireConnectionAsync(nameof(GetBroadcastTtlAsync), async () => {
try
{
ttl = await _apiController.GetBroadcastTtl(cid).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch broadcast TTL for {cid}", cid);
}
}).ConfigureAwait(false);
return ttl;
}
public async Task<Dictionary<string, BroadcastStatusInfoDto?>> AreUsersBroadcastingAsync(List<string> hashedCids)
{
Dictionary<string, BroadcastStatusInfoDto?> result = new();
await RequireConnectionAsync(nameof(AreUsersBroadcastingAsync), async () =>
{
try
{
var batch = await _apiController.AreUsersBroadcasting(hashedCids).ConfigureAwait(false);
if (batch?.Results != null)
{
foreach (var kv in batch.Results)
result[kv.Key] = kv.Value;
}
_logger.LogInformation("Batch broadcast status check complete for {Count} CIDs", hashedCids.Count);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to batch check broadcast status");
}
}).ConfigureAwait(false);
return result;
}
public async void ToggleBroadcast()
{
if (!IsLightFinderAvailable)
{
_logger.LogWarning("ToggleBroadcast - Lightfinder is not available.");
return;
}
await RequireConnectionAsync(nameof(ToggleBroadcast), async () =>
{
var cooldown = RemainingCooldown;
if (!_config.Current.BroadcastEnabled && cooldown is { } cd && cd > TimeSpan.Zero)
{
_logger.LogWarning("Cooldown active. Must wait {Remaining}s before re-enabling.", cd.TotalSeconds);
return;
}
var hashedCid = (await _dalamudUtil.GetCIDAsync().ConfigureAwait(false)).ToString().GetHash256();
try
{
var isCurrentlyBroadcasting = await CheckIfBroadcastingAsync(hashedCid).ConfigureAwait(false);
var newStatus = !isCurrentlyBroadcasting;
if (!newStatus)
{
_lastForcedDisableTime = DateTime.UtcNow;
_logger.LogInformation("Manual disable: cooldown timer started.");
}
_logger.LogInformation("Toggling broadcast. Server currently broadcasting: {ServerStatus}, setting to: {NewStatus}", isCurrentlyBroadcasting, newStatus);
_mediator.Publish(new EnableBroadcastMessage(hashedCid, newStatus));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to determine current broadcast status for toggle");
}
}).ConfigureAwait(false);
}
private async void OnTick(PriorityFrameworkUpdateMessage _)
{
if (!IsLightFinderAvailable)
return;
if (_config?.Current == null)
return;
if ((DateTime.UtcNow - _lastTtlCheck).TotalSeconds < 1)
return;
_lastTtlCheck = DateTime.UtcNow;
await RequireConnectionAsync(nameof(OnTick), async () => {
if (!_syncedOnStartup && _config.Current.BroadcastEnabled)
{
_syncedOnStartup = true;
try
{
var hashedCid = (await _dalamudUtil.GetCIDAsync().ConfigureAwait(false)).ToString().GetHash256();
var ttl = await GetBroadcastTtlAsync(hashedCid).ConfigureAwait(false);
if (ttl is { }
remaining && remaining > TimeSpan.Zero)
{
_config.Current.BroadcastTtl = DateTime.UtcNow + remaining;
_config.Current.BroadcastEnabled = true;
_config.Save();
_logger.LogInformation("Refreshed broadcast TTL from server on first OnTick: {TTL}", remaining);
}
else
{
_logger.LogWarning("No valid TTL found on OnTick. Disabling broadcast state.");
_config.Current.BroadcastEnabled = false;
_config.Current.BroadcastTtl = DateTime.MinValue;
_config.Save();
_mediator.Publish(new BroadcastStatusChangedMessage(false, null));
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh TTL in OnTick");
}
}
if (_config.Current.BroadcastEnabled)
{
if (_waitingForTtlFetch)
{
_logger.LogDebug("OnTick skipped: waiting for TTL fetch");
return;
}
var expiry = _config.Current.BroadcastTtl;
var remaining = expiry - DateTime.UtcNow;
_remainingTtl = remaining > TimeSpan.Zero ? remaining : null;
if (_remainingTtl == null)
{
_logger.LogInformation("Broadcast TTL expired. Disabling broadcast locally.");
_config.Current.BroadcastEnabled = false;
_config.Current.BroadcastTtl = DateTime.MinValue;
_config.Save();
_mediator.Publish(new BroadcastStatusChangedMessage(false, null));
}
}
else
{
_remainingTtl = null;
}
}).ConfigureAwait(false);
}
}

View File

@@ -313,7 +313,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return await RunOnFrameworkThread(() => _cid.Value.ToString().GetHash256()).ConfigureAwait(false);
}
private unsafe static string GetHashedCIDFromPlayerPointer(nint ptr)
public unsafe static string GetHashedCIDFromPlayerPointer(nint ptr)
{
return ((BattleChara*)ptr)->Character.ContentId.ToString().GetHash256();
}
@@ -421,6 +421,16 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return await RunOnFrameworkThread(() => IsObjectPresent(obj)).ConfigureAwait(false);
}
public IPlayerCharacter? GetPlayerByNameAndWorld(string name, ushort homeWorldId)
{
EnsureIsOnFramework();
return _objectTable
.OfType<IPlayerCharacter>()
.FirstOrDefault(p =>
string.Equals(p.Name.TextValue, name, StringComparison.Ordinal) &&
p.HomeWorld.RowId == homeWorldId);
}
public async Task RunOnFrameworkThread(System.Action act, [CallerMemberName] string callerMember = "", [CallerFilePath] string callerFilePath = "", [CallerLineNumber] int callerLineNumber = 0)
{
var fileName = Path.GetFileNameWithoutExtension(callerFilePath);

View File

@@ -97,7 +97,9 @@ public record GPoseLobbyReceiveCharaData(CharaDataDownloadDto CharaDataDownloadD
public record GPoseLobbyReceivePoseData(UserData UserData, PoseData PoseData) : MessageBase;
public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData) : MessageBase;
public record OpenCharaDataHubWithFilterMessage(UserData UserData) : MessageBase;
public record EnableBroadcastMessage(string HashedCid, bool Enabled) : MessageBase;
public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl) : MessageBase;
public record SyncshellBroadcastsUpdatedMessage : MessageBase;
public record VisibilityChange : MessageBase;
#pragma warning restore S2094
#pragma warning restore MA0048 // File name must match type name

View File

@@ -1,4 +1,5 @@
using Dalamud.Interface.Windowing;
using LightlessSync.UI.Style;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Services.Mediator;
@@ -33,6 +34,18 @@ public abstract class WindowMediatorSubscriberBase : Window, IMediatorSubscriber
GC.SuppressFinalize(this);
}
public override void PreDraw()
{
base.PreDraw();
MainStyle.PushStyle(); // internally checks ShouldUseTheme
}
public override void PostDraw()
{
MainStyle.PopStyle(); // always attempts to pop if pushed
base.PostDraw();
}
public override void Draw()
{
_performanceCollectorService.LogPerformance(this, $"Draw", DrawInternal);

View File

@@ -0,0 +1,297 @@
using Dalamud.Game.Addon.Lifecycle;
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.System.Framework;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI;
using LightlessSync.Services.Mediator;
using LightlessSync.Utils;
// Created using https://github.com/PunishedPineapple/Distance as a reference, thank you!
using Microsoft.Extensions.Logging;
namespace LightlessSync.Services;
public unsafe class NameplateHandler : IMediatorSubscriber
{
private readonly ILogger<NameplateHandler> _logger;
private readonly IAddonLifecycle _addonLifecycle;
private readonly IGameGui _gameGui;
private readonly DalamudUtilService _dalamudUtil;
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];
internal const uint mNameplateNodeIDBase = 0x7D99D500;
private volatile HashSet<string> _activeBroadcastingCids = new();
public NameplateHandler(ILogger<NameplateHandler> logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, DalamudUtilService dalamudUtil, LightlessMediator mediator)
{
_logger = logger;
_addonLifecycle = addonLifecycle;
_gameGui = gameGui;
_dalamudUtil = dalamudUtil;
_mediator = mediator;
}
internal void Init()
{
EnableNameplate();
_mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, OnTick);
}
internal void Uninit()
{
DisableNameplate();
DestroyNameplateNodes();
_mediator.Unsubscribe<PriorityFrameworkUpdateMessage>(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;
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}");
}
}
}
}
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);
//_logger.LogInformation($"checking cid: {cid}", cid);
if (cid == null || !_activeBroadcastingCids.Contains(cid))
{
pNode->AtkResNode.ToggleVisibility(false);
continue;
}
pNode->AtkResNode.ToggleVisibility(true);
var nameplateObject = mpNameplateAddon->NamePlateObjectArray[nameplateIndex];
nameplateObject.RootComponentNode->Component->UldManager.UpdateDrawNodeList();
var nameContainer = nameplateObject.NameContainer;
var nameText = nameplateObject.NameText;
var labelY = nameContainer->Height - nameplateObject.TextH - (int)(24 * nameText->AtkResNode.ScaleY);
pNode->AtkResNode.SetPositionShort(58, (short)labelY);
pNode->AtkResNode.SetUseDepthBasedPriority(true);
pNode->AtkResNode.SetScale(0.5f, 0.5f);
pNode->AtkResNode.Color.A = 255;
pNode->TextColor.A = 255;
pNode->TextColor.R = 173;
pNode->TextColor.G = 138;
pNode->TextColor.B = 245;
pNode->EdgeColor.A = 255;
pNode->EdgeColor.R = 0;
pNode->EdgeColor.G = 0;
pNode->EdgeColor.B = 0;
pNode->FontSize = 24;
pNode->AlignmentType = AlignmentType.Center;
pNode->FontType = FontType.MiedingerMed;
pNode->LineSpacing = 24;
pNode->CharSpacing = 1;
pNode->TextFlags = TextFlags.Edge | TextFlags.Glare;
pNode->SetText("Lightfinder");
}
}
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;
}
public void FlagRefresh()
{
_needsLabelRefresh = true;
}
public void OnTick(PriorityFrameworkUpdateMessage _)
{
if (_needsLabelRefresh)
{
UpdateNameplateNodes();
_needsLabelRefresh = false;
}
}
public void UpdateBroadcastingCids(IEnumerable<string> 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();
}
}

View File

@@ -3,47 +3,120 @@ using Dalamud.Game.Gui.NamePlate;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Plugin.Services;
using Dalamud.Utility;
using LightlessSync.API.Dto.User;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator;
using LightlessSync.UI;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
namespace LightlessSync.Services;
public class NameplateService : DisposableMediatorSubscriberBase
{
private readonly ILogger<NameplateService> _logger;
private readonly LightlessConfigService _configService;
private readonly IClientState _clientState;
private readonly INamePlateGui _namePlateGui;
private readonly PairManager _pairManager;
private readonly BroadcastService _broadcastService;
private readonly DalamudUtilService _dalamudUtil;
private readonly NameplateHandler _nameplatehandler;
private readonly IFramework _framework;
private readonly IGameGui _gameGui;
private readonly ConcurrentDictionary<string, BroadcastEntry> _broadcastCache = new();
private static readonly TimeSpan MaxAllowedTtl = TimeSpan.FromMinutes(5);
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
private readonly Queue<string> _lookupQueue = new();
private readonly HashSet<string> _lookupQueuedCids = new();
private readonly HashSet<string> _syncshellCids = new();
private readonly CancellationTokenSource _cleanupCts = new();
private Task? _cleanupTask;
private const int MaxLookupsPerFrame = 15;
private const int MaxQueueSize = 100;
private int _lookupsThisFrame = 0;
private int _frameCounter = 0;
public IReadOnlyDictionary<string, BroadcastEntry> BroadcastCache => _broadcastCache;
public readonly struct BroadcastEntry
{
public readonly bool IsBroadcasting;
public readonly DateTime ExpiryTime;
public readonly bool PrefixApplied;
public readonly string? GID;
public BroadcastEntry(bool isBroadcasting, DateTime expiryTime, bool prefixApplied, string? gid = null)
{
IsBroadcasting = isBroadcasting;
ExpiryTime = expiryTime;
PrefixApplied = prefixApplied;
GID = gid;
}
}
public NameplateService(ILogger<NameplateService> logger,
LightlessConfigService configService,
INamePlateGui namePlateGui,
IClientState clientState,
PairManager pairManager,
LightlessMediator lightlessMediator) : base(logger, lightlessMediator)
BroadcastService broadcastService,
LightlessMediator lightlessMediator,
DalamudUtilService dalamudUtil,
NameplateHandler nameplatehandler,
IGameGui gameGui) : base(logger, lightlessMediator)
{
_logger = logger;
_configService = configService;
_namePlateGui = namePlateGui;
_clientState = clientState;
_pairManager = pairManager;
_broadcastService = broadcastService;
_dalamudUtil = dalamudUtil;
_nameplatehandler = nameplatehandler;
_gameGui = gameGui;
_namePlateGui.OnNamePlateUpdate += OnNamePlateUpdate;
_namePlateGui.RequestRedraw();
Mediator.Subscribe<VisibilityChange>(this, (_) => _namePlateGui.RequestRedraw());
Mediator.Subscribe<BroadcastStatusChangedMessage>(this, OnBroadcastStatusChanged);
_nameplatehandler.Init();
_cleanupTask = Task.Run(ExpiredBroadcastCleanupLoop);
}
private void OnNamePlateUpdate(INamePlateUpdateContext context, IReadOnlyList<INamePlateUpdateHandler> handlers)
{
if (!_configService.Current.IsNameplateColorsEnabled || (_configService.Current.IsNameplateColorsEnabled && _clientState.IsPvPExcludingDen)) return;
var visibleUsersIds = _pairManager.GetOnlineUserPairs().Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue).Select(u => (ulong)u.PlayerCharacterId).ToHashSet();
_frameCounter++;
_lookupsThisFrame = 0;
if (!_configService.Current.IsNameplateColorsEnabled || (_configService.Current.IsNameplateColorsEnabled && _clientState.IsPvPExcludingDen))
return;
var visibleUsersIds = _pairManager.GetOnlineUserPairs()
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)
.ToHashSet();
var now = DateTime.UtcNow;
var colors = _configService.Current.NameplateColors;
foreach (var handler in handlers)
{
var playerCharacter = handler.PlayerCharacter;
if (playerCharacter == null) { continue; }
if (playerCharacter == null)
continue;
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(playerCharacter.Address);
var hasEntry = _broadcastCache.TryGetValue(cid, out var entry);
var isEntryStale = !hasEntry || entry.ExpiryTime <= now;
var isInParty = playerCharacter.StatusFlags.HasFlag(StatusFlags.PartyMember);
var isFriend = playerCharacter.StatusFlags.HasFlag(StatusFlags.Friend);
bool partyColorAllowed = (_configService.Current.overridePartyColor && isInParty);
@@ -55,9 +128,171 @@ public class NameplateService : DisposableMediatorSubscriberBase
(isFriend && !friendColorAllowed)
))
{
_logger.LogInformation("added nameplate color to {Name}", playerCharacter.Name.TextValue);
handler.NameParts.TextWrap = CreateTextWrap(colors);
}
if (!_broadcastService.IsBroadcasting)
continue;
if (isEntryStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < MaxQueueSize)
{
_lookupQueue.Enqueue(cid);
}
}
if (_broadcastService.IsBroadcasting && _frameCounter % 2 == 0)
{
var cidsToLookup = new List<string>();
while (_lookupQueue.Count > 0 && _lookupsThisFrame < MaxLookupsPerFrame)
{
var nextCid = _lookupQueue.Dequeue();
_lookupQueuedCids.Remove(nextCid);
cidsToLookup.Add(nextCid);
_lookupsThisFrame++;
}
if (cidsToLookup.Count > 0)
_ = BatchUpdateBroadcastCacheAsync(cidsToLookup);
}
}
private async Task BatchUpdateBroadcastCacheAsync(List<string> cidList)
{
var results = await _broadcastService.AreUsersBroadcastingAsync(cidList).ConfigureAwait(false);
var now = DateTime.UtcNow;
foreach (var (cid, info) in results)
{
if (string.IsNullOrWhiteSpace(cid) || info == null)
{
_logger.LogWarning("Skipping broadcast entry: cid={Cid}, info=null or empty", cid);
continue;
}
bool isBroadcasting = info.IsBroadcasting;
TimeSpan effectiveTtl = isBroadcasting && info.TTL.HasValue
? TimeSpan.FromTicks(Math.Min(info.TTL.Value.Ticks, MaxAllowedTtl.Ticks))
: RetryDelay;
var expiryTime = now + effectiveTtl;
_broadcastCache.AddOrUpdate(cid,
new BroadcastEntry(isBroadcasting, expiryTime, false, info.GID),
(_, old) => new BroadcastEntry(isBroadcasting, expiryTime, old.PrefixApplied, info.GID));
}
var activeCids = _broadcastCache
.Where(kvp => kvp.Value.IsBroadcasting)
.Select(kvp => kvp.Key)
.ToList();
_nameplatehandler.UpdateBroadcastingCids(activeCids);
_namePlateGui.RequestRedraw();
UpdateSyncshellBroadcasts();
}
private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg)
{
if (!msg.Enabled)
{
_logger.LogInformation("Broadcast disabled, clearing prefix cache and queue");
_broadcastCache.Clear();
_lookupQueue.Clear();
_lookupQueuedCids.Clear();
_syncshellCids.Clear();
_nameplatehandler.UpdateBroadcastingCids(Enumerable.Empty<string>());
_namePlateGui.RequestRedraw();
}
}
public List<BroadcastStatusInfoDto> GetActiveSyncshellBroadcasts()
{
var now = DateTime.UtcNow;
return _broadcastCache
.Where(kvp =>
kvp.Value.IsBroadcasting &&
kvp.Value.ExpiryTime > now &&
!string.IsNullOrEmpty(kvp.Value.GID))
.Select(kvp => new BroadcastStatusInfoDto
{
HashedCID = kvp.Key,
IsBroadcasting = true,
TTL = kvp.Value.ExpiryTime - now,
GID = kvp.Value.GID
})
.ToList();
}
private void UpdateSyncshellBroadcasts()
{
var now = DateTime.UtcNow;
var newSet = _broadcastCache
.Where(kvp => kvp.Value.IsBroadcasting && kvp.Value.ExpiryTime > now && !string.IsNullOrEmpty(kvp.Value.GID))
.Select(kvp => kvp.Key)
.ToHashSet();
if (!_syncshellCids.SetEquals(newSet))
{
_syncshellCids.Clear();
foreach (var cid in newSet)
_syncshellCids.Add(cid);
_logger.LogInformation("Syncshell broadcast entries changed, sending update lol");
Mediator.Publish(new SyncshellBroadcastsUpdatedMessage());
}
}
public bool IsBroadcastingKnown(string cidHash, out bool isBroadcasting)
{
if (_broadcastCache.TryGetValue(cidHash, out var entry))
{
isBroadcasting = entry.IsBroadcasting;
return true;
}
isBroadcasting = false;
return false;
}
private async Task ExpiredBroadcastCleanupLoop()
{
var token = _cleanupCts.Token;
try
{
while (!token.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(10), token);
var now = DateTime.UtcNow;
foreach (var (cid, entry) in _broadcastCache.ToArray())
{
if (entry.ExpiryTime <= now)
{
if (_broadcastCache.TryRemove(cid, out _))
{
_logger.LogInformation("Removed expired broadcast entry: {Cid}", cid);
}
}
}
}
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in ExpiredBroadcastCleanupLoop");
}
UpdateSyncshellBroadcasts();
}
public void RequestRedraw()
@@ -80,12 +315,15 @@ public class NameplateService : DisposableMediatorSubscriberBase
return (left.ToReadOnlySeString().ToDalamudString(), right.ToReadOnlySeString().ToDalamudString());
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
_cleanupCts.Cancel();
_cleanupTask?.Wait(100);
_namePlateGui.OnNamePlateUpdate -= OnNamePlateUpdate;
_namePlateGui.RequestRedraw();
_nameplatehandler.Uninit();
}
}
}