🎉 Initial Version

This commit is contained in:
Liza 2023-02-25 23:31:52 +01:00
parent 38347015b2
commit acec50fd3d
Signed by: liza
GPG Key ID: 7199F8D727D55F67
10 changed files with 491 additions and 19 deletions

View File

@ -0,0 +1,8 @@
namespace RetainerTrack.Database
{
internal sealed class Player
{
public ulong Id { get; set; }
public string? Name { get; set; }
}
}

View File

@ -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; }
}
}

View File

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

View File

@ -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<MarketBoardUiHandler> _logger;
private readonly Framework _framework;
private readonly GameGui _gameGui;
private readonly PersistenceContext _persistenceContext;
private AddonItemSearchResult* _itemSearchResultAddon;
private Hook<Draw>? _drawHook;
private delegate void Draw(AtkUnitBase* addon);
public MarketBoardUiHandler(
ILogger<MarketBoardUiHandler> 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<Draw>.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;
}
}
}

View File

@ -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<NetworkHandler> _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<NetworkHandler> 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 : "<unknown>");
Task.Run(() => _persistenceContext.HandleContentIdMapping(mapping));
}
}
}
}

View File

@ -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<ContentIdToName> 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<ContentIdToName> 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;
}
}
}

View File

@ -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<PersistenceContext> _logger;
private readonly ClientState _clientState;
private readonly LiteDatabase _liteDatabase;
private readonly ConcurrentDictionary<uint, ConcurrentDictionary<string, ulong>> _worldRetainerCache = new();
private readonly ConcurrentDictionary<ulong, string> _playerNameCache = new();
public PersistenceContext(ILogger<PersistenceContext> logger, ClientState clientState,
LiteDatabase liteDatabase)
{
_logger = logger;
_clientState = clientState;
_liteDatabase = liteDatabase;
var retainersByWorld = _liteDatabase.GetCollection<Retainer>().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<Player>().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<Retainer>().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<ContentIdToName> { mapping });
public void HandleContentIdMapping(IReadOnlyList<ContentIdToName> 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<Player>().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");
}
}
}
}

View File

@ -19,7 +19,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dalamud.Extensions.MicrosoftLogging" Version="1.0.0"/>
<PackageReference Include="DalamudPackager" Version="2.1.10"/>
<PackageReference Include="LiteDB" Version="5.0.15"/>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0"/>
</ItemGroup>
<ItemGroup>

View File

@ -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<IDalamudPlugin>(this);
serviceCollection.AddSingleton(pluginInterface);
serviceCollection.AddSingleton(gameNetwork);
serviceCollection.AddSingleton(dataManager);
serviceCollection.AddSingleton(framework);
serviceCollection.AddSingleton(clientState);
serviceCollection.AddSingleton(gameGui);
serviceCollection.AddSingleton<LiteDatabase>(_ =>
new LiteDatabase(new ConnectionString
{
Filename = Path.Join(pluginInterface.GetPluginConfigDirectory(), "retainer-data.litedb"),
Connection = ConnectionType.Direct,
Upgrade = true,
}));
serviceCollection.AddSingleton<PersistenceContext>();
serviceCollection.AddSingleton<NetworkHandler>();
serviceCollection.AddSingleton<PartyHandler>();
serviceCollection.AddSingleton<MarketBoardUiHandler>();
_serviceProvider = serviceCollection.BuildServiceProvider();
LiteDatabase liteDatabase = _serviceProvider.GetRequiredService<LiteDatabase>();
liteDatabase.GetCollection<Retainer>()
.EnsureIndex(x => x.Id);
liteDatabase.GetCollection<Player>()
.EnsureIndex(x => x.Id);
_serviceProvider.GetRequiredService<PartyHandler>();
_serviceProvider.GetRequiredService<NetworkHandler>();
_serviceProvider.GetRequiredService<MarketBoardUiHandler>();
}
public void Dispose()
{
_serviceProvider?.Dispose();
}
}
}