diff --git a/RetainerTrack/DalamudPackager.targets b/RetainerTrack/DalamudPackager.targets index b335e59..37c782a 100644 --- a/RetainerTrack/DalamudPackager.targets +++ b/RetainerTrack/DalamudPackager.targets @@ -1,20 +1,20 @@  - - - + + + - - - + + + diff --git a/RetainerTrack/Database/Player.cs b/RetainerTrack/Database/Player.cs new file mode 100644 index 0000000..ccda75a --- /dev/null +++ b/RetainerTrack/Database/Player.cs @@ -0,0 +1,8 @@ +namespace RetainerTrack.Database +{ + internal sealed class Player + { + public ulong Id { get; set; } + public string? Name { get; set; } + } +} diff --git a/RetainerTrack/Database/Retainer.cs b/RetainerTrack/Database/Retainer.cs new file mode 100644 index 0000000..4456dda --- /dev/null +++ b/RetainerTrack/Database/Retainer.cs @@ -0,0 +1,10 @@ +namespace RetainerTrack.Database +{ + internal sealed class Retainer + { + public ulong Id { get; set; } + public string? Name { get; set; } + public ushort WorldId { get; set; } + public ulong OwnerContentId { get; set; } + } +} diff --git a/RetainerTrack/Handlers/ContentIdToName.cs b/RetainerTrack/Handlers/ContentIdToName.cs new file mode 100644 index 0000000..c9fdc4d --- /dev/null +++ b/RetainerTrack/Handlers/ContentIdToName.cs @@ -0,0 +1,23 @@ +using System.IO; +using System.Text; + +namespace RetainerTrack.Handlers +{ + internal sealed class ContentIdToName + { + public ulong ContentId { get; init; } + public string PlayerName { get; init; } = string.Empty; + + public static unsafe ContentIdToName Read(nint dataPtr) + { + using UnmanagedMemoryStream input = new UnmanagedMemoryStream((byte*)dataPtr.ToPointer(), 40); + using BinaryReader binaryReader = new BinaryReader(input); + + return new ContentIdToName + { + ContentId = binaryReader.ReadUInt64(), + PlayerName = Encoding.UTF8.GetString(binaryReader.ReadBytes(32)).TrimEnd(char.MinValue) + }; + } + } +} diff --git a/RetainerTrack/Handlers/MarketBoardUIHandler.cs b/RetainerTrack/Handlers/MarketBoardUIHandler.cs new file mode 100644 index 0000000..90c2920 --- /dev/null +++ b/RetainerTrack/Handlers/MarketBoardUIHandler.cs @@ -0,0 +1,101 @@ +using System; +using Dalamud.Game; +using Dalamud.Game.Gui; +using Dalamud.Hooking; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; +using Microsoft.Extensions.Logging; + +namespace RetainerTrack.Handlers +{ + internal sealed unsafe class MarketBoardUiHandler : IDisposable + { + private readonly ILogger _logger; + private readonly Framework _framework; + private readonly GameGui _gameGui; + private readonly PersistenceContext _persistenceContext; + + private AddonItemSearchResult* _itemSearchResultAddon; + private Hook? _drawHook; + + private delegate void Draw(AtkUnitBase* addon); + + public MarketBoardUiHandler( + ILogger logger, + Framework framework, + GameGui gameGui, + PersistenceContext persistenceContext) + { + _logger = logger; + _framework = framework; + _gameGui = gameGui; + _persistenceContext = persistenceContext; + ; + + _framework.Update += FrameworkUpdate; + } + + private void FrameworkUpdate(Framework framework) + { + _itemSearchResultAddon = (AddonItemSearchResult*)_gameGui.GetAddonByName("ItemSearchResult"); + if (_itemSearchResultAddon == null) + return; + + _drawHook ??= Hook.FromAddress( + new nint(_itemSearchResultAddon->AtkUnitBase.AtkEventListener.vfunc[42]), + AddonDraw); + _drawHook.Enable(); + _framework.Update -= FrameworkUpdate; + } + + private void AddonDraw(AtkUnitBase* addon) + { + UpdateRetainerNames(); + _drawHook!.Original(addon); + } + + private void UpdateRetainerNames() + { + try + { + if (_itemSearchResultAddon == null || !_itemSearchResultAddon->AtkUnitBase.IsVisible) + return; + + var results = _itemSearchResultAddon->Results; + if (results == null) + return; + + int length = results->ListLength; + if (length == 0) + return; + + for (int i = 0; i < length; ++i) + { + var listItem = results->ItemRendererList[i].AtkComponentListItemRenderer; + var uldManager = listItem->AtkComponentButton.AtkComponentBase.UldManager; + if (uldManager.NodeListCount < 14) + continue; + + var retainerNameNode = (AtkTextNode*)uldManager.NodeList[5]; + string retainerName = retainerNameNode->NodeText.ToString(); + if (!retainerName.Contains('(')) + { + string playerName = _persistenceContext.GetCharacterNameOnCurrentWorld(retainerName); + if (!string.IsNullOrEmpty(playerName)) + retainerNameNode->SetText($"{playerName} ({retainerName})"); + } + } + } + catch (Exception e) + { + _logger.LogInformation(e, "Market board draw failed"); + } + } + + public void Dispose() + { + _drawHook?.Dispose(); + _framework.Update -= FrameworkUpdate; + } + } +} diff --git a/RetainerTrack/Handlers/NetworkHandler.cs b/RetainerTrack/Handlers/NetworkHandler.cs new file mode 100644 index 0000000..80076aa --- /dev/null +++ b/RetainerTrack/Handlers/NetworkHandler.cs @@ -0,0 +1,78 @@ +using System; +using System.Threading.Tasks; +using Dalamud.Data; +using Dalamud.Game.ClientState; +using Dalamud.Game.Network; +using Dalamud.Game.Network.Structures; +using Microsoft.Extensions.Logging; +using Framework = FFXIVClientStructs.FFXIV.Client.System.Framework.Framework; + +namespace RetainerTrack.Handlers +{ + internal sealed class NetworkHandler : IDisposable + { + private readonly ILogger _logger; + private readonly GameNetwork _gameNetwork; + private readonly DataManager _dataManager; + private readonly ClientState _clientState; + private readonly PersistenceContext _persistenceContext; + + private readonly ushort? _contentIdMappingOpCode; + + public unsafe NetworkHandler( + ILogger logger, + GameNetwork gameNetwork, + DataManager dataManager, + ClientState clientState, + PersistenceContext persistenceContext) + { + _logger = logger; + _gameNetwork = gameNetwork; + _dataManager = dataManager; + _clientState = clientState; + _persistenceContext = persistenceContext; + + if (Framework.Instance()->GameVersion.Base == "2023.02.03.0000.0000") + _contentIdMappingOpCode = 0x01C4; + else + { + _logger.LogWarning("Not tracking content id mappings, unsupported game version {Version}", + Framework.Instance()->GameVersion.Base); + } + + _gameNetwork.NetworkMessage += NetworkMessage; + } + + public void Dispose() + { + _gameNetwork.NetworkMessage -= NetworkMessage; + } + + private void NetworkMessage(nint dataPtr, ushort opcode, uint sourceActorId, uint targetActorId, + NetworkMessageDirection direction) + { + if (direction != NetworkMessageDirection.ZoneDown || !_dataManager.IsDataReady) + return; + + if (opcode == _dataManager.ServerOpCodes["MarketBoardOfferings"]) + { + ushort worldId = (ushort?)_clientState.LocalPlayer?.CurrentWorld.Id ?? 0; + if (worldId == 0) + { + _logger.LogInformation("Skipping market board handler, current world unknown"); + return; + } + + var listings = MarketBoardCurrentOfferings.Read(dataPtr); + Task.Run(() => _persistenceContext.HandleMarketBoardPage(listings, worldId)); + } + else if (opcode == _contentIdMappingOpCode) + { + var mapping = ContentIdToName.Read(dataPtr); + _logger.LogTrace("Content id {ContentId} belongs to player '{Name}'", mapping.ContentId, + !string.IsNullOrEmpty(mapping.PlayerName) ? mapping.PlayerName : ""); + Task.Run(() => _persistenceContext.HandleContentIdMapping(mapping)); + } + } + } +} diff --git a/RetainerTrack/Handlers/PartyHandler.cs b/RetainerTrack/Handlers/PartyHandler.cs new file mode 100644 index 0000000..9fecc70 --- /dev/null +++ b/RetainerTrack/Handlers/PartyHandler.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Dalamud.Game; +using Dalamud.Game.ClientState; +using Dalamud.Memory; +using FFXIVClientStructs.FFXIV.Client.Game.Group; + +namespace RetainerTrack.Handlers +{ + internal sealed class PartyHandler : IDisposable + { + private readonly Framework _framework; + private readonly ClientState _clientState; + private readonly PersistenceContext _persistenceContext; + + private long _lastUpdate = 0; + + public PartyHandler(Framework framework, ClientState clientState, PersistenceContext persistenceContext) + { + _framework = framework; + _clientState = clientState; + _persistenceContext = persistenceContext; + + _framework.Update += FrameworkUpdate; + } + + private unsafe void FrameworkUpdate(Framework _) + { + long now = Environment.TickCount64; + if (!_clientState.IsLoggedIn || _clientState.IsPvPExcludingDen || now - _lastUpdate < 180_000) + return; + + _lastUpdate = now; + List mappings = new(); + + var groupManager = GroupManager.Instance(); + foreach (var partyMember in groupManager->PartyMembersSpan) + HandlePartyMember(partyMember, mappings); + + foreach (var allianceMember in groupManager->AllianceMembersSpan) + HandlePartyMember(allianceMember, mappings); + + if (mappings.Count > 0) + Task.Run(() => _persistenceContext.HandleContentIdMapping(mappings)); + } + + private unsafe void HandlePartyMember(PartyMember partyMember, List contentIdToNames) + { + if (partyMember.ContentID == 0) + return; + + string name = MemoryHelper.ReadStringNullTerminated((nint)partyMember.Name); + contentIdToNames.Add(new ContentIdToName + { + ContentId = (ulong)partyMember.ContentID, + PlayerName = name, + }); + } + + public void Dispose() + { + _framework.Update -= FrameworkUpdate; + } + } +} diff --git a/RetainerTrack/Handlers/PersistenceContext.cs b/RetainerTrack/Handlers/PersistenceContext.cs new file mode 100644 index 0000000..70f5f67 --- /dev/null +++ b/RetainerTrack/Handlers/PersistenceContext.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Dalamud.Game.ClientState; +using Dalamud.Game.Network.Structures; +using LiteDB; +using Microsoft.Extensions.Logging; +using RetainerTrack.Database; + +namespace RetainerTrack.Handlers +{ + internal sealed class PersistenceContext + { + private readonly ILogger _logger; + private readonly ClientState _clientState; + private readonly LiteDatabase _liteDatabase; + private readonly ConcurrentDictionary> _worldRetainerCache = new(); + private readonly ConcurrentDictionary _playerNameCache = new(); + + public PersistenceContext(ILogger logger, ClientState clientState, + LiteDatabase liteDatabase) + { + _logger = logger; + _clientState = clientState; + _liteDatabase = liteDatabase; + + var retainersByWorld = _liteDatabase.GetCollection().FindAll() + .GroupBy(r => r.WorldId); + foreach (var retainers in retainersByWorld) + { + var world = _worldRetainerCache.GetOrAdd(retainers.Key, _ => new()); + foreach (var retainer in retainers) + { + if (retainer.Name != null) + world[retainer.Name] = retainer.OwnerContentId; + } + } + + foreach (var player in _liteDatabase.GetCollection().FindAll()) + _playerNameCache[player.Id] = player.Name ?? string.Empty; + } + + public string GetCharacterNameOnCurrentWorld(string retainerName) + { + uint currentWorld = _clientState.LocalPlayer?.CurrentWorld.Id ?? 0; + if (currentWorld == 0) + return string.Empty; + + var currentWorldCache = _worldRetainerCache.GetOrAdd(currentWorld, _ => new()); + if (!currentWorldCache.TryGetValue(retainerName, out ulong playerContentId)) + return string.Empty; + + return _playerNameCache.TryGetValue(playerContentId, out string? playerName) ? playerName : string.Empty; + } + + public void HandleMarketBoardPage(MarketBoardCurrentOfferings listings, ushort worldId) + { + try + { + var updates = + listings.ItemListings.DistinctBy(o => o.RetainerId) + .Where(l => l.RetainerId != 0) + .Where(l => l.RetainerOwnerId != 0) + .Select(l => + new Retainer + { + Id = l.RetainerId, + Name = l.RetainerName, + WorldId = worldId, + OwnerContentId = l.RetainerOwnerId, + }) + .ToList(); + _liteDatabase.GetCollection().Upsert(updates); + foreach (var retainer in updates) + { + if (!_playerNameCache.TryGetValue(retainer.OwnerContentId, out string? ownerName)) + ownerName = retainer.OwnerContentId.ToString(); + _logger.LogTrace("Retainer {RetainerName} belongs to {OwnerId}", retainer.Name, + ownerName); + + if (retainer.Name != null) + { + var world = _worldRetainerCache.GetOrAdd(retainer.WorldId, _ => new()); + world[retainer.Name] = retainer.OwnerContentId; + } + } + } + catch (Exception e) + { + _logger.LogError(e, "Could not persist retainer info from market board page"); + } + } + + public void HandleContentIdMapping(ContentIdToName mapping) + => HandleContentIdMapping(new List { mapping }); + + public void HandleContentIdMapping(IReadOnlyList mappings) + { + try + { + var updates = mappings + .Where(mapping => mapping.ContentId != 0 && !string.IsNullOrEmpty(mapping.PlayerName)) + .Select(mapping => + new Player + { + Id = mapping.ContentId, + Name = mapping.PlayerName, + }) + .ToList(); + _liteDatabase.GetCollection().Upsert(updates); + foreach (var player in updates) + _playerNameCache[player.Id] = player.Name ?? string.Empty; + } + catch (Exception e) + { + _logger.LogError(e, "Could not persist multiple mappings"); + } + } + } +} diff --git a/RetainerTrack/RetainerTrack.csproj b/RetainerTrack/RetainerTrack.csproj index 153c37e..dc508f3 100644 --- a/RetainerTrack/RetainerTrack.csproj +++ b/RetainerTrack/RetainerTrack.csproj @@ -19,7 +19,10 @@ - + + + + diff --git a/RetainerTrack/RetainerTrackPlugin.cs b/RetainerTrack/RetainerTrackPlugin.cs index ba85d64..e91e534 100644 --- a/RetainerTrack/RetainerTrackPlugin.cs +++ b/RetainerTrack/RetainerTrackPlugin.cs @@ -1,13 +1,75 @@ -using Dalamud.Plugin; +using System.IO; +using Dalamud.Data; +using Dalamud.Extensions.MicrosoftLogging; +using Dalamud.Game; +using Dalamud.Game.ClientState; +using Dalamud.Game.Gui; +using Dalamud.Game.Network; +using Dalamud.Plugin; +using LiteDB; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using RetainerTrack.Database; +using RetainerTrack.Handlers; namespace RetainerTrack { - public class RetainerTrackPlugin : IDalamudPlugin + // ReSharper disable once UnusedType.Global + internal sealed class RetainerTrackPlugin : IDalamudPlugin { + private readonly ServiceProvider? _serviceProvider; + public string Name => "RetainerTrack"; + public RetainerTrackPlugin( + DalamudPluginInterface pluginInterface, + GameNetwork gameNetwork, + DataManager dataManager, + Framework framework, + ClientState clientState, + GameGui gameGui) + { + ServiceCollection serviceCollection = new(); + serviceCollection.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Trace) + .ClearProviders() + .AddDalamudLogger(this)); + serviceCollection.AddSingleton(this); + serviceCollection.AddSingleton(pluginInterface); + serviceCollection.AddSingleton(gameNetwork); + serviceCollection.AddSingleton(dataManager); + serviceCollection.AddSingleton(framework); + serviceCollection.AddSingleton(clientState); + serviceCollection.AddSingleton(gameGui); + + serviceCollection.AddSingleton(_ => + new LiteDatabase(new ConnectionString + { + Filename = Path.Join(pluginInterface.GetPluginConfigDirectory(), "retainer-data.litedb"), + Connection = ConnectionType.Direct, + Upgrade = true, + })); + + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + + _serviceProvider = serviceCollection.BuildServiceProvider(); + + LiteDatabase liteDatabase = _serviceProvider.GetRequiredService(); + liteDatabase.GetCollection() + .EnsureIndex(x => x.Id); + liteDatabase.GetCollection() + .EnsureIndex(x => x.Id); + + _serviceProvider.GetRequiredService(); + _serviceProvider.GetRequiredService(); + _serviceProvider.GetRequiredService(); + } + public void Dispose() { + _serviceProvider?.Dispose(); } } }