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();
}
}
}