Initial
This commit is contained in:
156
MareSynchronos/Services/CharaData/CharaDataCharacterHandler.cs
Normal file
156
MareSynchronos/Services/CharaData/CharaDataCharacterHandler.cs
Normal file
@@ -0,0 +1,156 @@
|
||||
using MareSynchronos.API.Data.Enum;
|
||||
using MareSynchronos.Interop.Ipc;
|
||||
using MareSynchronos.PlayerData.Factories;
|
||||
using MareSynchronos.PlayerData.Handlers;
|
||||
using MareSynchronos.Services.CharaData.Models;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
public sealed class CharaDataCharacterHandler : DisposableMediatorSubscriberBase
|
||||
{
|
||||
private readonly GameObjectHandlerFactory _gameObjectHandlerFactory;
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
private readonly IpcManager _ipcManager;
|
||||
private readonly NoSnapService _noSnapService;
|
||||
private readonly Dictionary<string, HandledCharaDataEntry> _handledCharaData = new(StringComparer.Ordinal);
|
||||
|
||||
public IReadOnlyDictionary<string, HandledCharaDataEntry> HandledCharaData => _handledCharaData;
|
||||
|
||||
public CharaDataCharacterHandler(ILogger<CharaDataCharacterHandler> logger, MareMediator mediator,
|
||||
GameObjectHandlerFactory gameObjectHandlerFactory, DalamudUtilService dalamudUtilService,
|
||||
IpcManager ipcManager, NoSnapService noSnapService)
|
||||
: base(logger, mediator)
|
||||
{
|
||||
_gameObjectHandlerFactory = gameObjectHandlerFactory;
|
||||
_dalamudUtilService = dalamudUtilService;
|
||||
_ipcManager = ipcManager;
|
||||
_noSnapService = noSnapService;
|
||||
mediator.Subscribe<GposeEndMessage>(this, msg =>
|
||||
{
|
||||
foreach (var chara in _handledCharaData)
|
||||
{
|
||||
_ = RevertHandledChara(chara.Value);
|
||||
}
|
||||
});
|
||||
|
||||
mediator.Subscribe<CutsceneFrameworkUpdateMessage>(this, (_) => HandleCutsceneFrameworkUpdate());
|
||||
}
|
||||
|
||||
private void HandleCutsceneFrameworkUpdate()
|
||||
{
|
||||
if (!_dalamudUtilService.IsInGpose) return;
|
||||
|
||||
foreach (var entry in _handledCharaData.Values.ToList())
|
||||
{
|
||||
var chara = _dalamudUtilService.GetGposeCharacterFromObjectTableByName(entry.Name, onlyGposeCharacters: true);
|
||||
if (chara is null)
|
||||
{
|
||||
_handledCharaData.Remove(entry.Name);
|
||||
_ = _dalamudUtilService.RunOnFrameworkThread(() => RevertChara(entry.Name, entry.CustomizePlus));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
foreach (var chara in _handledCharaData.Values)
|
||||
{
|
||||
_ = RevertHandledChara(chara);
|
||||
}
|
||||
}
|
||||
|
||||
public HandledCharaDataEntry? GetHandledCharacter(string name)
|
||||
{
|
||||
return _handledCharaData.GetValueOrDefault(name);
|
||||
}
|
||||
|
||||
public async Task RevertChara(string name, Guid? cPlusId)
|
||||
{
|
||||
Guid applicationId = Guid.NewGuid();
|
||||
await _ipcManager.Glamourer.RevertByNameAsync(Logger, name, applicationId).ConfigureAwait(false);
|
||||
if (cPlusId != null)
|
||||
{
|
||||
await _ipcManager.CustomizePlus.RevertByIdAsync(cPlusId).ConfigureAwait(false);
|
||||
}
|
||||
using var handler = await _gameObjectHandlerFactory.Create(ObjectKind.Player,
|
||||
() => _dalamudUtilService.GetGposeCharacterFromObjectTableByName(name, _dalamudUtilService.IsInGpose)?.Address ?? IntPtr.Zero, false)
|
||||
.ConfigureAwait(false);
|
||||
if (handler.Address != nint.Zero)
|
||||
await _ipcManager.Penumbra.RedrawAsync(Logger, handler, applicationId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<bool> RevertHandledChara(string name)
|
||||
{
|
||||
var handled = _handledCharaData.GetValueOrDefault(name);
|
||||
return await RevertHandledChara(handled).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<bool> RevertHandledChara(HandledCharaDataEntry? handled)
|
||||
{
|
||||
if (handled == null) return false;
|
||||
_handledCharaData.Remove(handled.Name);
|
||||
await _dalamudUtilService.RunOnFrameworkThread(async () =>
|
||||
{
|
||||
RemoveGposer(handled);
|
||||
await RevertChara(handled.Name, handled.CustomizePlus).ConfigureAwait(false);
|
||||
}).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void AddHandledChara(HandledCharaDataEntry handledCharaDataEntry)
|
||||
{
|
||||
_handledCharaData.Add(handledCharaDataEntry.Name, handledCharaDataEntry);
|
||||
_ = _dalamudUtilService.RunOnFrameworkThread(() => AddGposer(handledCharaDataEntry));
|
||||
}
|
||||
|
||||
public void UpdateHandledData(Dictionary<string, CharaDataMetaInfoExtendedDto?> newData)
|
||||
{
|
||||
foreach (var handledData in _handledCharaData.Values)
|
||||
{
|
||||
if (newData.TryGetValue(handledData.MetaInfo.FullId, out var metaInfo) && metaInfo != null)
|
||||
{
|
||||
handledData.MetaInfo = metaInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<GameObjectHandler?> TryCreateGameObjectHandler(string name, bool gPoseOnly = false)
|
||||
{
|
||||
var handler = await _gameObjectHandlerFactory.Create(ObjectKind.Player,
|
||||
() => _dalamudUtilService.GetGposeCharacterFromObjectTableByName(name, gPoseOnly && _dalamudUtilService.IsInGpose)?.Address ?? IntPtr.Zero, false)
|
||||
.ConfigureAwait(false);
|
||||
if (handler.Address == nint.Zero) return null;
|
||||
return handler;
|
||||
}
|
||||
|
||||
public async Task<GameObjectHandler?> TryCreateGameObjectHandler(int index)
|
||||
{
|
||||
var handler = await _gameObjectHandlerFactory.Create(ObjectKind.Player,
|
||||
() => _dalamudUtilService.GetCharacterFromObjectTableByIndex(index)?.Address ?? IntPtr.Zero, false)
|
||||
.ConfigureAwait(false);
|
||||
if (handler.Address == nint.Zero) return null;
|
||||
return handler;
|
||||
}
|
||||
|
||||
private int GetGposerObjectIndex(string name)
|
||||
{
|
||||
return _dalamudUtilService.GetGposeCharacterFromObjectTableByName(name, _dalamudUtilService.IsInGpose)?.ObjectIndex ?? -1;
|
||||
}
|
||||
|
||||
private void AddGposer(HandledCharaDataEntry handled)
|
||||
{
|
||||
int objectIndex = GetGposerObjectIndex(handled.Name);
|
||||
if (objectIndex > 0)
|
||||
_noSnapService.AddGposer(objectIndex);
|
||||
}
|
||||
|
||||
private void RemoveGposer(HandledCharaDataEntry handled)
|
||||
{
|
||||
int objectIndex = GetGposerObjectIndex(handled.Name);
|
||||
if (objectIndex > 0)
|
||||
_noSnapService.RemoveGposer(objectIndex);
|
||||
}
|
||||
}
|
302
MareSynchronos/Services/CharaData/CharaDataFileHandler.cs
Normal file
302
MareSynchronos/Services/CharaData/CharaDataFileHandler.cs
Normal file
@@ -0,0 +1,302 @@
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using K4os.Compression.LZ4.Legacy;
|
||||
using MareSynchronos.API.Data;
|
||||
using MareSynchronos.API.Data.Enum;
|
||||
using MareSynchronos.API.Dto.CharaData;
|
||||
using MareSynchronos.FileCache;
|
||||
using MareSynchronos.PlayerData.Factories;
|
||||
using MareSynchronos.PlayerData.Handlers;
|
||||
using MareSynchronos.Services.CharaData;
|
||||
using MareSynchronos.Services.CharaData.Models;
|
||||
using MareSynchronos.Utils;
|
||||
using MareSynchronos.WebAPI.Files;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
public sealed class CharaDataFileHandler : IDisposable
|
||||
{
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
private readonly FileCacheManager _fileCacheManager;
|
||||
private readonly FileDownloadManager _fileDownloadManager;
|
||||
private readonly FileUploadManager _fileUploadManager;
|
||||
private readonly GameObjectHandlerFactory _gameObjectHandlerFactory;
|
||||
private readonly ILogger<CharaDataFileHandler> _logger;
|
||||
private readonly MareCharaFileDataFactory _mareCharaFileDataFactory;
|
||||
private readonly PlayerDataFactory _playerDataFactory;
|
||||
private int _globalFileCounter = 0;
|
||||
|
||||
public CharaDataFileHandler(ILogger<CharaDataFileHandler> logger, FileDownloadManagerFactory fileDownloadManagerFactory, FileUploadManager fileUploadManager, FileCacheManager fileCacheManager,
|
||||
DalamudUtilService dalamudUtilService, GameObjectHandlerFactory gameObjectHandlerFactory, PlayerDataFactory playerDataFactory)
|
||||
{
|
||||
_fileDownloadManager = fileDownloadManagerFactory.Create();
|
||||
_logger = logger;
|
||||
_fileUploadManager = fileUploadManager;
|
||||
_fileCacheManager = fileCacheManager;
|
||||
_dalamudUtilService = dalamudUtilService;
|
||||
_gameObjectHandlerFactory = gameObjectHandlerFactory;
|
||||
_playerDataFactory = playerDataFactory;
|
||||
_mareCharaFileDataFactory = new(fileCacheManager);
|
||||
}
|
||||
|
||||
public void ComputeMissingFiles(CharaDataDownloadDto charaDataDownloadDto, out Dictionary<string, string> modPaths, out List<FileReplacementData> missingFiles)
|
||||
{
|
||||
modPaths = [];
|
||||
missingFiles = [];
|
||||
foreach (var file in charaDataDownloadDto.FileGamePaths)
|
||||
{
|
||||
var localCacheFile = _fileCacheManager.GetFileCacheByHash(file.HashOrFileSwap);
|
||||
if (localCacheFile == null)
|
||||
{
|
||||
var existingFile = missingFiles.Find(f => string.Equals(f.Hash, file.HashOrFileSwap, StringComparison.Ordinal));
|
||||
if (existingFile == null)
|
||||
{
|
||||
missingFiles.Add(new FileReplacementData()
|
||||
{
|
||||
Hash = file.HashOrFileSwap,
|
||||
GamePaths = [file.GamePath]
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existingFile.GamePaths = existingFile.GamePaths.Concat([file.GamePath]).ToArray();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
modPaths[file.GamePath] = localCacheFile.ResolvedFilepath;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var swap in charaDataDownloadDto.FileSwaps)
|
||||
{
|
||||
modPaths[swap.GamePath] = swap.HashOrFileSwap;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CharacterData?> CreatePlayerData()
|
||||
{
|
||||
var chara = await _dalamudUtilService.GetPlayerCharacterAsync().ConfigureAwait(false);
|
||||
if (_dalamudUtilService.IsInGpose)
|
||||
{
|
||||
chara = (IPlayerCharacter?)(await _dalamudUtilService.GetGposeCharacterFromObjectTableByNameAsync(chara.Name.TextValue, _dalamudUtilService.IsInGpose).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
if (chara == null)
|
||||
return null;
|
||||
|
||||
using var tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Player,
|
||||
() => _dalamudUtilService.GetCharacterFromObjectTableByIndex(chara.ObjectIndex)?.Address ?? IntPtr.Zero, isWatched: false).ConfigureAwait(false);
|
||||
PlayerData.Data.CharacterData newCdata = new();
|
||||
await _playerDataFactory.BuildCharacterData(newCdata, tempHandler, CancellationToken.None).ConfigureAwait(false);
|
||||
if (newCdata.FileReplacements.TryGetValue(ObjectKind.Player, out var playerData) && playerData != null)
|
||||
{
|
||||
foreach (var data in playerData.Select(g => g.GamePaths))
|
||||
{
|
||||
data.RemoveWhere(g => g.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)
|
||||
|| g.EndsWith(".tmb", StringComparison.OrdinalIgnoreCase)
|
||||
|| g.EndsWith(".scd", StringComparison.OrdinalIgnoreCase)
|
||||
|| (g.EndsWith(".avfx", StringComparison.OrdinalIgnoreCase)
|
||||
&& !g.Contains("/weapon/", StringComparison.OrdinalIgnoreCase)
|
||||
&& !g.Contains("/equipment/", StringComparison.OrdinalIgnoreCase))
|
||||
|| (g.EndsWith(".atex", StringComparison.OrdinalIgnoreCase)
|
||||
&& !g.Contains("/weapon/", StringComparison.OrdinalIgnoreCase)
|
||||
&& !g.Contains("/equipment/", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
playerData.RemoveWhere(g => g.GamePaths.Count == 0);
|
||||
}
|
||||
|
||||
return newCdata.ToAPI();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_fileDownloadManager.Dispose();
|
||||
}
|
||||
|
||||
public async Task DownloadFilesAsync(GameObjectHandler tempHandler, List<FileReplacementData> missingFiles, Dictionary<string, string> modPaths, CancellationToken token)
|
||||
{
|
||||
await _fileDownloadManager.InitiateDownloadList(tempHandler, missingFiles, token).ConfigureAwait(false);
|
||||
await _fileDownloadManager.DownloadFiles(tempHandler, missingFiles, token).ConfigureAwait(false);
|
||||
token.ThrowIfCancellationRequested();
|
||||
foreach (var file in missingFiles.SelectMany(m => m.GamePaths, (FileEntry, GamePath) => (FileEntry.Hash, GamePath)))
|
||||
{
|
||||
var localFile = _fileCacheManager.GetFileCacheByHash(file.Hash)?.ResolvedFilepath;
|
||||
if (localFile == null)
|
||||
{
|
||||
throw new FileNotFoundException("File not found locally.");
|
||||
}
|
||||
modPaths[file.GamePath] = localFile;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<(MareCharaFileHeader loadedCharaFile, long expectedLength)> LoadCharaFileHeader(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var unwrapped = File.OpenRead(filePath);
|
||||
using var lz4Stream = new LZ4Stream(unwrapped, LZ4StreamMode.Decompress, LZ4StreamFlags.HighCompression);
|
||||
using var reader = new BinaryReader(lz4Stream);
|
||||
var loadedCharaFile = MareCharaFileHeader.FromBinaryReader(filePath, reader);
|
||||
|
||||
_logger.LogInformation("Read Mare Chara File");
|
||||
_logger.LogInformation("Version: {ver}", (loadedCharaFile?.Version ?? -1));
|
||||
long expectedLength = 0;
|
||||
if (loadedCharaFile != null)
|
||||
{
|
||||
_logger.LogTrace("Data");
|
||||
foreach (var item in loadedCharaFile.CharaFileData.FileSwaps)
|
||||
{
|
||||
foreach (var gamePath in item.GamePaths)
|
||||
{
|
||||
_logger.LogTrace("Swap: {gamePath} => {fileSwapPath}", gamePath, item.FileSwapPath);
|
||||
}
|
||||
}
|
||||
|
||||
var itemNr = 0;
|
||||
foreach (var item in loadedCharaFile.CharaFileData.Files)
|
||||
{
|
||||
itemNr++;
|
||||
expectedLength += item.Length;
|
||||
foreach (var gamePath in item.GamePaths)
|
||||
{
|
||||
_logger.LogTrace("File {itemNr}: {gamePath} = {len}", itemNr, gamePath, item.Length.ToByteString());
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Expected length: {expected}", expectedLength.ToByteString());
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("MCDF Header was null");
|
||||
}
|
||||
return Task.FromResult((loadedCharaFile, expectedLength));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not parse MCDF header of file {file}", filePath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Dictionary<string, string> McdfExtractFiles(MareCharaFileHeader? charaFileHeader, long expectedLength, List<string> extractedFiles)
|
||||
{
|
||||
if (charaFileHeader == null) return [];
|
||||
|
||||
using var lz4Stream = new LZ4Stream(File.OpenRead(charaFileHeader.FilePath), LZ4StreamMode.Decompress, LZ4StreamFlags.HighCompression);
|
||||
using var reader = new BinaryReader(lz4Stream);
|
||||
MareCharaFileHeader.AdvanceReaderToData(reader);
|
||||
|
||||
long totalRead = 0;
|
||||
Dictionary<string, string> gamePathToFilePath = new(StringComparer.Ordinal);
|
||||
foreach (var fileData in charaFileHeader.CharaFileData.Files)
|
||||
{
|
||||
var fileName = Path.Combine(_fileCacheManager.CacheFolder, "mare_" + _globalFileCounter++ + ".tmp");
|
||||
extractedFiles.Add(fileName);
|
||||
var length = fileData.Length;
|
||||
var bufferSize = length;
|
||||
using var fs = File.OpenWrite(fileName);
|
||||
using var wr = new BinaryWriter(fs);
|
||||
_logger.LogTrace("Reading {length} of {fileName}", length.ToByteString(), fileName);
|
||||
var buffer = reader.ReadBytes(bufferSize);
|
||||
wr.Write(buffer);
|
||||
wr.Flush();
|
||||
wr.Close();
|
||||
if (buffer.Length == 0) throw new EndOfStreamException("Unexpected EOF");
|
||||
foreach (var path in fileData.GamePaths)
|
||||
{
|
||||
gamePathToFilePath[path] = fileName;
|
||||
_logger.LogTrace("{path} => {fileName} [{hash}]", path, fileName, fileData.Hash);
|
||||
}
|
||||
totalRead += length;
|
||||
_logger.LogTrace("Read {read}/{expected} bytes", totalRead.ToByteString(), expectedLength.ToByteString());
|
||||
}
|
||||
|
||||
return gamePathToFilePath;
|
||||
}
|
||||
|
||||
public async Task UpdateCharaDataAsync(CharaDataExtendedUpdateDto updateDto)
|
||||
{
|
||||
var data = await CreatePlayerData().ConfigureAwait(false);
|
||||
|
||||
if (data != null)
|
||||
{
|
||||
var hasGlamourerData = data.GlamourerData.TryGetValue(ObjectKind.Player, out var playerDataString);
|
||||
if (!hasGlamourerData) updateDto.GlamourerData = null;
|
||||
else updateDto.GlamourerData = playerDataString;
|
||||
|
||||
var hasCustomizeData = data.CustomizePlusData.TryGetValue(ObjectKind.Player, out var customizeDataString);
|
||||
if (!hasCustomizeData) updateDto.CustomizeData = null;
|
||||
else updateDto.CustomizeData = customizeDataString;
|
||||
|
||||
updateDto.ManipulationData = data.ManipulationData;
|
||||
|
||||
var hasFiles = data.FileReplacements.TryGetValue(ObjectKind.Player, out var fileReplacements);
|
||||
if (!hasFiles)
|
||||
{
|
||||
updateDto.FileGamePaths = [];
|
||||
updateDto.FileSwaps = [];
|
||||
}
|
||||
else
|
||||
{
|
||||
updateDto.FileGamePaths = [.. fileReplacements!.Where(u => string.IsNullOrEmpty(u.FileSwapPath)).SelectMany(u => u.GamePaths, (file, path) => new GamePathEntry(file.Hash, path))];
|
||||
updateDto.FileSwaps = [.. fileReplacements!.Where(u => !string.IsNullOrEmpty(u.FileSwapPath)).SelectMany(u => u.GamePaths, (file, path) => new GamePathEntry(file.FileSwapPath, path))];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task SaveCharaFileAsync(string description, string filePath)
|
||||
{
|
||||
var tempFilePath = filePath + ".tmp";
|
||||
|
||||
try
|
||||
{
|
||||
var data = await CreatePlayerData().ConfigureAwait(false);
|
||||
if (data == null) return;
|
||||
|
||||
var mareCharaFileData = _mareCharaFileDataFactory.Create(description, data);
|
||||
MareCharaFileHeader output = new(MareCharaFileHeader.CurrentVersion, mareCharaFileData);
|
||||
|
||||
using var fs = new FileStream(tempFilePath, FileMode.Create, FileAccess.ReadWrite, FileShare.None);
|
||||
using var lz4 = new LZ4Stream(fs, LZ4StreamMode.Compress, LZ4StreamFlags.HighCompression);
|
||||
using var writer = new BinaryWriter(lz4);
|
||||
output.WriteToStream(writer);
|
||||
|
||||
foreach (var item in output.CharaFileData.Files)
|
||||
{
|
||||
var file = _fileCacheManager.GetFileCacheByHash(item.Hash)!;
|
||||
_logger.LogDebug("Saving to MCDF: {hash}:{file}", item.Hash, file.ResolvedFilepath);
|
||||
_logger.LogDebug("\tAssociated GamePaths:");
|
||||
foreach (var path in item.GamePaths)
|
||||
{
|
||||
_logger.LogDebug("\t{path}", path);
|
||||
}
|
||||
|
||||
var fsRead = File.OpenRead(file.ResolvedFilepath);
|
||||
await using (fsRead.ConfigureAwait(false))
|
||||
{
|
||||
using var br = new BinaryReader(fsRead);
|
||||
byte[] buffer = new byte[item.Length];
|
||||
br.Read(buffer, 0, item.Length);
|
||||
writer.Write(buffer);
|
||||
}
|
||||
}
|
||||
writer.Flush();
|
||||
await lz4.FlushAsync().ConfigureAwait(false);
|
||||
await fs.FlushAsync().ConfigureAwait(false);
|
||||
fs.Close();
|
||||
File.Move(tempFilePath, filePath, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failure Saving Mare Chara File, deleting output");
|
||||
File.Delete(tempFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<List<string>> UploadFiles(List<string> fileList, ValueProgress<string> uploadProgress, CancellationToken token)
|
||||
{
|
||||
return await _fileUploadManager.UploadFiles(fileList, uploadProgress, token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
@@ -0,0 +1,696 @@
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using MareSynchronos.API.Data;
|
||||
using MareSynchronos.API.Dto.CharaData;
|
||||
using MareSynchronos.Interop;
|
||||
using MareSynchronos.Interop.Ipc;
|
||||
using MareSynchronos.Services.CharaData.Models;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using MareSynchronos.WebAPI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace MareSynchronos.Services.CharaData;
|
||||
|
||||
public class CharaDataGposeTogetherManager : DisposableMediatorSubscriberBase
|
||||
{
|
||||
private readonly ApiController _apiController;
|
||||
private readonly IpcCallerBrio _brio;
|
||||
private readonly SemaphoreSlim _charaDataCreationSemaphore = new(1, 1);
|
||||
private readonly CharaDataFileHandler _charaDataFileHandler;
|
||||
private readonly CharaDataManager _charaDataManager;
|
||||
private readonly DalamudUtilService _dalamudUtil;
|
||||
private readonly Dictionary<string, GposeLobbyUserData> _usersInLobby = [];
|
||||
private readonly VfxSpawnManager _vfxSpawnManager;
|
||||
private (CharacterData ApiData, CharaDataDownloadDto Dto)? _lastCreatedCharaData;
|
||||
private PoseData? _lastDeltaPoseData;
|
||||
private PoseData? _lastFullPoseData;
|
||||
private WorldData? _lastWorldData;
|
||||
private CancellationTokenSource _lobbyCts = new();
|
||||
private int _poseGenerationExecutions = 0;
|
||||
|
||||
public CharaDataGposeTogetherManager(ILogger<CharaDataGposeTogetherManager> logger, MareMediator mediator,
|
||||
ApiController apiController, IpcCallerBrio brio, DalamudUtilService dalamudUtil, VfxSpawnManager vfxSpawnManager,
|
||||
CharaDataFileHandler charaDataFileHandler, CharaDataManager charaDataManager) : base(logger, mediator)
|
||||
{
|
||||
Mediator.Subscribe<GposeLobbyUserJoin>(this, (msg) =>
|
||||
{
|
||||
OnUserJoinLobby(msg.UserData);
|
||||
});
|
||||
Mediator.Subscribe<GPoseLobbyUserLeave>(this, (msg) =>
|
||||
{
|
||||
OnUserLeaveLobby(msg.UserData);
|
||||
});
|
||||
Mediator.Subscribe<GPoseLobbyReceiveCharaData>(this, (msg) =>
|
||||
{
|
||||
OnReceiveCharaData(msg.CharaDataDownloadDto);
|
||||
});
|
||||
Mediator.Subscribe<GPoseLobbyReceivePoseData>(this, (msg) =>
|
||||
{
|
||||
OnReceivePoseData(msg.UserData, msg.PoseData);
|
||||
});
|
||||
Mediator.Subscribe<GPoseLobbyReceiveWorldData>(this, (msg) =>
|
||||
{
|
||||
OnReceiveWorldData(msg.UserData, msg.WorldData);
|
||||
});
|
||||
Mediator.Subscribe<ConnectedMessage>(this, (msg) =>
|
||||
{
|
||||
if (_usersInLobby.Count > 0 && !string.IsNullOrEmpty(CurrentGPoseLobbyId))
|
||||
{
|
||||
JoinGPoseLobby(CurrentGPoseLobbyId, isReconnecting: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
LeaveGPoseLobby();
|
||||
}
|
||||
});
|
||||
Mediator.Subscribe<GposeStartMessage>(this, (msg) =>
|
||||
{
|
||||
OnEnterGpose();
|
||||
});
|
||||
Mediator.Subscribe<GposeEndMessage>(this, (msg) =>
|
||||
{
|
||||
OnExitGpose();
|
||||
});
|
||||
Mediator.Subscribe<FrameworkUpdateMessage>(this, (msg) =>
|
||||
{
|
||||
OnFrameworkUpdate();
|
||||
});
|
||||
Mediator.Subscribe<CutsceneFrameworkUpdateMessage>(this, (msg) =>
|
||||
{
|
||||
OnCutsceneFrameworkUpdate();
|
||||
});
|
||||
Mediator.Subscribe<DisconnectedMessage>(this, (msg) =>
|
||||
{
|
||||
LeaveGPoseLobby();
|
||||
});
|
||||
|
||||
_apiController = apiController;
|
||||
_brio = brio;
|
||||
_dalamudUtil = dalamudUtil;
|
||||
_vfxSpawnManager = vfxSpawnManager;
|
||||
_charaDataFileHandler = charaDataFileHandler;
|
||||
_charaDataManager = charaDataManager;
|
||||
}
|
||||
|
||||
public string? CurrentGPoseLobbyId { get; private set; }
|
||||
public string? LastGPoseLobbyId { get; private set; }
|
||||
|
||||
public IEnumerable<GposeLobbyUserData> UsersInLobby => _usersInLobby.Values;
|
||||
|
||||
public (bool SameMap, bool SameServer, bool SameEverything) IsOnSameMapAndServer(GposeLobbyUserData data)
|
||||
{
|
||||
return (data.Map.RowId == _lastWorldData?.LocationInfo.MapId, data.WorldData?.LocationInfo.ServerId == _lastWorldData?.LocationInfo.ServerId, data.WorldData?.LocationInfo == _lastWorldData?.LocationInfo);
|
||||
}
|
||||
|
||||
public async Task PushCharacterDownloadDto()
|
||||
{
|
||||
var playerData = await _charaDataFileHandler.CreatePlayerData().ConfigureAwait(false);
|
||||
if (playerData == null) return;
|
||||
if (!string.Equals(playerData.DataHash.Value, _lastCreatedCharaData?.ApiData.DataHash.Value, StringComparison.Ordinal))
|
||||
{
|
||||
List<GamePathEntry> filegamePaths = [.. playerData.FileReplacements[API.Data.Enum.ObjectKind.Player]
|
||||
.Where(u => string.IsNullOrEmpty(u.FileSwapPath)).SelectMany(u => u.GamePaths, (file, path) => new GamePathEntry(file.Hash, path))];
|
||||
List<GamePathEntry> fileSwapPaths = [.. playerData.FileReplacements[API.Data.Enum.ObjectKind.Player]
|
||||
.Where(u => !string.IsNullOrEmpty(u.FileSwapPath)).SelectMany(u => u.GamePaths, (file, path) => new GamePathEntry(file.FileSwapPath, path))];
|
||||
await _charaDataManager.UploadFiles([.. playerData.FileReplacements[API.Data.Enum.ObjectKind.Player]
|
||||
.Where(u => string.IsNullOrEmpty(u.FileSwapPath)).SelectMany(u => u.GamePaths, (file, path) => new GamePathEntry(file.Hash, path))])
|
||||
.ConfigureAwait(false);
|
||||
|
||||
CharaDataDownloadDto charaDataDownloadDto = new($"GPOSELOBBY:{CurrentGPoseLobbyId}", new(_apiController.UID))
|
||||
{
|
||||
UpdatedDate = DateTime.UtcNow,
|
||||
ManipulationData = playerData.ManipulationData,
|
||||
CustomizeData = playerData.CustomizePlusData[API.Data.Enum.ObjectKind.Player],
|
||||
FileGamePaths = filegamePaths,
|
||||
FileSwaps = fileSwapPaths,
|
||||
GlamourerData = playerData.GlamourerData[API.Data.Enum.ObjectKind.Player],
|
||||
};
|
||||
|
||||
_lastCreatedCharaData = (playerData, charaDataDownloadDto);
|
||||
}
|
||||
|
||||
ForceResendOwnData();
|
||||
|
||||
if (_lastCreatedCharaData != null)
|
||||
await _apiController.GposeLobbyPushCharacterData(_lastCreatedCharaData.Value.Dto)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal void CreateNewLobby()
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
ClearLobby();
|
||||
CurrentGPoseLobbyId = await _apiController.GposeLobbyCreate().ConfigureAwait(false);
|
||||
if (!string.IsNullOrEmpty(CurrentGPoseLobbyId))
|
||||
{
|
||||
_ = GposeWorldPositionBackgroundTask(_lobbyCts.Token);
|
||||
_ = GposePoseDataBackgroundTask(_lobbyCts.Token);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
internal void JoinGPoseLobby(string joinLobbyId, bool isReconnecting = false)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var otherUsers = await _apiController.GposeLobbyJoin(joinLobbyId).ConfigureAwait(false);
|
||||
ClearLobby();
|
||||
if (otherUsers.Any())
|
||||
{
|
||||
LastGPoseLobbyId = string.Empty;
|
||||
|
||||
foreach (var user in otherUsers)
|
||||
{
|
||||
OnUserJoinLobby(user);
|
||||
}
|
||||
|
||||
CurrentGPoseLobbyId = joinLobbyId;
|
||||
_ = GposeWorldPositionBackgroundTask(_lobbyCts.Token);
|
||||
_ = GposePoseDataBackgroundTask(_lobbyCts.Token);
|
||||
}
|
||||
else
|
||||
{
|
||||
LeaveGPoseLobby();
|
||||
LastGPoseLobbyId = string.Empty;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
internal void LeaveGPoseLobby()
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var left = await _apiController.GposeLobbyLeave().ConfigureAwait(false);
|
||||
if (left)
|
||||
{
|
||||
if (_usersInLobby.Count != 0)
|
||||
{
|
||||
LastGPoseLobbyId = CurrentGPoseLobbyId;
|
||||
}
|
||||
|
||||
ClearLobby(revertCharas: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (disposing)
|
||||
{
|
||||
ClearLobby(revertCharas: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearLobby(bool revertCharas = false)
|
||||
{
|
||||
_lobbyCts.Cancel();
|
||||
_lobbyCts.Dispose();
|
||||
_lobbyCts = new();
|
||||
CurrentGPoseLobbyId = string.Empty;
|
||||
foreach (var user in _usersInLobby.ToDictionary())
|
||||
{
|
||||
if (revertCharas)
|
||||
_charaDataManager.RevertChara(user.Value.HandledChara);
|
||||
OnUserLeaveLobby(user.Value.UserData);
|
||||
}
|
||||
_usersInLobby.Clear();
|
||||
}
|
||||
|
||||
private string CreateJsonFromPoseData(PoseData? poseData)
|
||||
{
|
||||
if (poseData == null) return "{}";
|
||||
|
||||
var node = new JsonObject();
|
||||
node["Bones"] = new JsonObject();
|
||||
foreach (var bone in poseData.Value.Bones)
|
||||
{
|
||||
node["Bones"]![bone.Key] = new JsonObject();
|
||||
node["Bones"]![bone.Key]!["Position"] = $"{bone.Value.PositionX.ToString(CultureInfo.InvariantCulture)}, {bone.Value.PositionY.ToString(CultureInfo.InvariantCulture)}, {bone.Value.PositionZ.ToString(CultureInfo.InvariantCulture)}";
|
||||
node["Bones"]![bone.Key]!["Scale"] = $"{bone.Value.ScaleX.ToString(CultureInfo.InvariantCulture)}, {bone.Value.ScaleY.ToString(CultureInfo.InvariantCulture)}, {bone.Value.ScaleZ.ToString(CultureInfo.InvariantCulture)}";
|
||||
node["Bones"]![bone.Key]!["Rotation"] = $"{bone.Value.RotationX.ToString(CultureInfo.InvariantCulture)}, {bone.Value.RotationY.ToString(CultureInfo.InvariantCulture)}, {bone.Value.RotationZ.ToString(CultureInfo.InvariantCulture)}, {bone.Value.RotationW.ToString(CultureInfo.InvariantCulture)}";
|
||||
}
|
||||
node["MainHand"] = new JsonObject();
|
||||
foreach (var bone in poseData.Value.MainHand)
|
||||
{
|
||||
node["MainHand"]![bone.Key] = new JsonObject();
|
||||
node["MainHand"]![bone.Key]!["Position"] = $"{bone.Value.PositionX.ToString(CultureInfo.InvariantCulture)}, {bone.Value.PositionY.ToString(CultureInfo.InvariantCulture)}, {bone.Value.PositionZ.ToString(CultureInfo.InvariantCulture)}";
|
||||
node["MainHand"]![bone.Key]!["Scale"] = $"{bone.Value.ScaleX.ToString(CultureInfo.InvariantCulture)}, {bone.Value.ScaleY.ToString(CultureInfo.InvariantCulture)}, {bone.Value.ScaleZ.ToString(CultureInfo.InvariantCulture)}";
|
||||
node["MainHand"]![bone.Key]!["Rotation"] = $"{bone.Value.RotationX.ToString(CultureInfo.InvariantCulture)}, {bone.Value.RotationY.ToString(CultureInfo.InvariantCulture)}, {bone.Value.RotationZ.ToString(CultureInfo.InvariantCulture)}, {bone.Value.RotationW.ToString(CultureInfo.InvariantCulture)}";
|
||||
}
|
||||
node["OffHand"] = new JsonObject();
|
||||
foreach (var bone in poseData.Value.OffHand)
|
||||
{
|
||||
node["OffHand"]![bone.Key] = new JsonObject();
|
||||
node["OffHand"]![bone.Key]!["Position"] = $"{bone.Value.PositionX.ToString(CultureInfo.InvariantCulture)}, {bone.Value.PositionY.ToString(CultureInfo.InvariantCulture)}, {bone.Value.PositionZ.ToString(CultureInfo.InvariantCulture)}";
|
||||
node["OffHand"]![bone.Key]!["Scale"] = $"{bone.Value.ScaleX.ToString(CultureInfo.InvariantCulture)}, {bone.Value.ScaleY.ToString(CultureInfo.InvariantCulture)}, {bone.Value.ScaleZ.ToString(CultureInfo.InvariantCulture)}";
|
||||
node["OffHand"]![bone.Key]!["Rotation"] = $"{bone.Value.RotationX.ToString(CultureInfo.InvariantCulture)}, {bone.Value.RotationY.ToString(CultureInfo.InvariantCulture)}, {bone.Value.RotationZ.ToString(CultureInfo.InvariantCulture)}, {bone.Value.RotationW.ToString(CultureInfo.InvariantCulture)}";
|
||||
}
|
||||
|
||||
return node.ToJsonString();
|
||||
}
|
||||
|
||||
private PoseData CreatePoseDataFromJson(string json, PoseData? fullPoseData = null)
|
||||
{
|
||||
PoseData output = new();
|
||||
output.Bones = new(StringComparer.Ordinal);
|
||||
output.MainHand = new(StringComparer.Ordinal);
|
||||
output.OffHand = new(StringComparer.Ordinal);
|
||||
|
||||
float getRounded(string number)
|
||||
{
|
||||
return float.Round(float.Parse(number, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture), 5);
|
||||
}
|
||||
|
||||
BoneData createBoneData(JsonNode boneJson)
|
||||
{
|
||||
BoneData outputBoneData = new();
|
||||
outputBoneData.Exists = true;
|
||||
var posString = boneJson["Position"]!.ToString();
|
||||
var pos = posString.Split(",", StringSplitOptions.TrimEntries);
|
||||
outputBoneData.PositionX = getRounded(pos[0]);
|
||||
outputBoneData.PositionY = getRounded(pos[1]);
|
||||
outputBoneData.PositionZ = getRounded(pos[2]);
|
||||
|
||||
var scaString = boneJson["Scale"]!.ToString();
|
||||
var sca = scaString.Split(",", StringSplitOptions.TrimEntries);
|
||||
outputBoneData.ScaleX = getRounded(sca[0]);
|
||||
outputBoneData.ScaleY = getRounded(sca[1]);
|
||||
outputBoneData.ScaleZ = getRounded(sca[2]);
|
||||
|
||||
var rotString = boneJson["Rotation"]!.ToString();
|
||||
var rot = rotString.Split(",", StringSplitOptions.TrimEntries);
|
||||
outputBoneData.RotationX = getRounded(rot[0]);
|
||||
outputBoneData.RotationY = getRounded(rot[1]);
|
||||
outputBoneData.RotationZ = getRounded(rot[2]);
|
||||
outputBoneData.RotationW = getRounded(rot[3]);
|
||||
return outputBoneData;
|
||||
}
|
||||
|
||||
var node = JsonNode.Parse(json)!;
|
||||
var bones = node["Bones"]!.AsObject();
|
||||
foreach (var bone in bones)
|
||||
{
|
||||
string name = bone.Key;
|
||||
var boneJson = bone.Value!.AsObject();
|
||||
BoneData outputBoneData = createBoneData(boneJson);
|
||||
|
||||
if (fullPoseData != null)
|
||||
{
|
||||
if (fullPoseData.Value.Bones.TryGetValue(name, out var prevBoneData) && prevBoneData != outputBoneData)
|
||||
{
|
||||
output.Bones[name] = outputBoneData;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
output.Bones[name] = outputBoneData;
|
||||
}
|
||||
}
|
||||
var mainHand = node["MainHand"]!.AsObject();
|
||||
foreach (var bone in mainHand)
|
||||
{
|
||||
string name = bone.Key;
|
||||
var boneJson = bone.Value!.AsObject();
|
||||
BoneData outputBoneData = createBoneData(boneJson);
|
||||
|
||||
if (fullPoseData != null)
|
||||
{
|
||||
if (fullPoseData.Value.MainHand.TryGetValue(name, out var prevBoneData) && prevBoneData != outputBoneData)
|
||||
{
|
||||
output.MainHand[name] = outputBoneData;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
output.MainHand[name] = outputBoneData;
|
||||
}
|
||||
}
|
||||
var offhand = node["OffHand"]!.AsObject();
|
||||
foreach (var bone in offhand)
|
||||
{
|
||||
string name = bone.Key;
|
||||
var boneJson = bone.Value!.AsObject();
|
||||
BoneData outputBoneData = createBoneData(boneJson);
|
||||
|
||||
if (fullPoseData != null)
|
||||
{
|
||||
if (fullPoseData.Value.OffHand.TryGetValue(name, out var prevBoneData) && prevBoneData != outputBoneData)
|
||||
{
|
||||
output.OffHand[name] = outputBoneData;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
output.OffHand[name] = outputBoneData;
|
||||
}
|
||||
}
|
||||
|
||||
if (fullPoseData != null)
|
||||
output.IsDelta = true;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private async Task GposePoseDataBackgroundTask(CancellationToken ct)
|
||||
{
|
||||
_lastFullPoseData = null;
|
||||
_lastDeltaPoseData = null;
|
||||
_poseGenerationExecutions = 0;
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false);
|
||||
if (!_dalamudUtil.IsInGpose) continue;
|
||||
if (_usersInLobby.Count == 0) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var chara = await _dalamudUtil.GetPlayerCharacterAsync().ConfigureAwait(false);
|
||||
if (_dalamudUtil.IsInGpose)
|
||||
{
|
||||
chara = (IPlayerCharacter?)(await _dalamudUtil.GetGposeCharacterFromObjectTableByNameAsync(chara.Name.TextValue, _dalamudUtil.IsInGpose).ConfigureAwait(false));
|
||||
}
|
||||
if (chara == null || chara.Address == nint.Zero) continue;
|
||||
|
||||
var poseJson = await _brio.GetPoseAsync(chara.Address).ConfigureAwait(false);
|
||||
if (string.IsNullOrEmpty(poseJson)) continue;
|
||||
|
||||
var lastFullData = _poseGenerationExecutions++ >= 12 ? null : _lastFullPoseData;
|
||||
lastFullData = _forceResendFullPose ? _lastFullPoseData : lastFullData;
|
||||
|
||||
var poseData = CreatePoseDataFromJson(poseJson, lastFullData);
|
||||
if (!poseData.IsDelta)
|
||||
{
|
||||
_lastFullPoseData = poseData;
|
||||
_lastDeltaPoseData = null;
|
||||
_poseGenerationExecutions = 0;
|
||||
}
|
||||
|
||||
bool deltaIsSame = _lastDeltaPoseData != null &&
|
||||
(poseData.Bones.Keys.All(k => _lastDeltaPoseData.Value.Bones.ContainsKey(k)
|
||||
&& poseData.Bones.Values.All(k => _lastDeltaPoseData.Value.Bones.ContainsValue(k))));
|
||||
|
||||
if (_forceResendFullPose || ((poseData.Bones.Any() || poseData.MainHand.Any() || poseData.OffHand.Any())
|
||||
&& (!poseData.IsDelta || (poseData.IsDelta && !deltaIsSame))))
|
||||
{
|
||||
_forceResendFullPose = false;
|
||||
await _apiController.GposeLobbyPushPoseData(poseData).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (poseData.IsDelta)
|
||||
_lastDeltaPoseData = poseData;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Error during Pose Data Generation");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GposeWorldPositionBackgroundTask(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(_dalamudUtil.IsInGpose ? 2 : 1), ct).ConfigureAwait(false);
|
||||
|
||||
// if there are no players in lobby, don't do anything
|
||||
if (_usersInLobby.Count == 0) continue;
|
||||
|
||||
try
|
||||
{
|
||||
// get own player data
|
||||
var player = (Dalamud.Game.ClientState.Objects.Types.ICharacter?)(await _dalamudUtil.GetPlayerCharacterAsync().ConfigureAwait(false));
|
||||
if (player == null) continue;
|
||||
WorldData worldData;
|
||||
if (_dalamudUtil.IsInGpose)
|
||||
{
|
||||
player = await _dalamudUtil.GetGposeCharacterFromObjectTableByNameAsync(player.Name.TextValue, true).ConfigureAwait(false);
|
||||
if (player == null) continue;
|
||||
worldData = (await _brio.GetTransformAsync(player.Address).ConfigureAwait(false));
|
||||
}
|
||||
else
|
||||
{
|
||||
var rotQuaternion = Quaternion.CreateFromAxisAngle(new Vector3(0, 1, 0), player.Rotation);
|
||||
worldData = new()
|
||||
{
|
||||
PositionX = player.Position.X,
|
||||
PositionY = player.Position.Y,
|
||||
PositionZ = player.Position.Z,
|
||||
RotationW = rotQuaternion.W,
|
||||
RotationX = rotQuaternion.X,
|
||||
RotationY = rotQuaternion.Y,
|
||||
RotationZ = rotQuaternion.Z,
|
||||
ScaleX = 1,
|
||||
ScaleY = 1,
|
||||
ScaleZ = 1
|
||||
};
|
||||
}
|
||||
|
||||
var loc = await _dalamudUtil.GetMapDataAsync().ConfigureAwait(false);
|
||||
worldData.LocationInfo = loc;
|
||||
|
||||
if (_forceResendWorldData || worldData != _lastWorldData)
|
||||
{
|
||||
_forceResendWorldData = false;
|
||||
await _apiController.GposeLobbyPushWorldData(worldData).ConfigureAwait(false);
|
||||
_lastWorldData = worldData;
|
||||
Logger.LogTrace("WorldData (gpose: {gpose}): {data}", _dalamudUtil.IsInGpose, worldData);
|
||||
}
|
||||
|
||||
foreach (var entry in _usersInLobby)
|
||||
{
|
||||
if (!entry.Value.HasWorldDataUpdate || _dalamudUtil.IsInGpose || entry.Value.WorldData == null) continue;
|
||||
|
||||
var entryWorldData = entry.Value.WorldData!.Value;
|
||||
|
||||
if (worldData.LocationInfo.MapId == entryWorldData.LocationInfo.MapId && worldData.LocationInfo.DivisionId == entryWorldData.LocationInfo.DivisionId
|
||||
&& (worldData.LocationInfo.HouseId != entryWorldData.LocationInfo.HouseId
|
||||
|| worldData.LocationInfo.WardId != entryWorldData.LocationInfo.WardId
|
||||
|| entryWorldData.LocationInfo.ServerId != worldData.LocationInfo.ServerId))
|
||||
{
|
||||
if (entry.Value.SpawnedVfxId == null)
|
||||
{
|
||||
// spawn if it doesn't exist yet
|
||||
entry.Value.LastWorldPosition = new Vector3(entryWorldData.PositionX, entryWorldData.PositionY, entryWorldData.PositionZ);
|
||||
entry.Value.SpawnedVfxId = await _dalamudUtil.RunOnFrameworkThread(() => _vfxSpawnManager.SpawnObject(entry.Value.LastWorldPosition.Value,
|
||||
Quaternion.Identity, Vector3.One, 0.5f, 0.1f, 0.5f, 0.9f)).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// move object via lerp if it does exist
|
||||
var newPosition = new Vector3(entryWorldData.PositionX, entryWorldData.PositionY, entryWorldData.PositionZ);
|
||||
if (newPosition != entry.Value.LastWorldPosition)
|
||||
{
|
||||
entry.Value.UpdateStart = DateTime.UtcNow;
|
||||
entry.Value.TargetWorldPosition = newPosition;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await _dalamudUtil.RunOnFrameworkThread(() => _vfxSpawnManager.DespawnObject(entry.Value.SpawnedVfxId)).ConfigureAwait(false);
|
||||
entry.Value.SpawnedVfxId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Error during World Data Generation");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCutsceneFrameworkUpdate()
|
||||
{
|
||||
foreach (var kvp in _usersInLobby)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(kvp.Value.AssociatedCharaName))
|
||||
{
|
||||
kvp.Value.Address = _dalamudUtil.GetGposeCharacterFromObjectTableByName(kvp.Value.AssociatedCharaName, true)?.Address ?? nint.Zero;
|
||||
if (kvp.Value.Address == nint.Zero)
|
||||
{
|
||||
kvp.Value.AssociatedCharaName = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
if (kvp.Value.Address != nint.Zero && (kvp.Value.HasWorldDataUpdate || kvp.Value.HasPoseDataUpdate))
|
||||
{
|
||||
bool hadPoseDataUpdate = kvp.Value.HasPoseDataUpdate;
|
||||
bool hadWorldDataUpdate = kvp.Value.HasWorldDataUpdate;
|
||||
kvp.Value.HasPoseDataUpdate = false;
|
||||
kvp.Value.HasWorldDataUpdate = false;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
if (hadPoseDataUpdate && kvp.Value.ApplicablePoseData != null)
|
||||
{
|
||||
await _brio.SetPoseAsync(kvp.Value.Address, CreateJsonFromPoseData(kvp.Value.ApplicablePoseData)).ConfigureAwait(false);
|
||||
}
|
||||
if (hadWorldDataUpdate && kvp.Value.WorldData != null)
|
||||
{
|
||||
await _brio.ApplyTransformAsync(kvp.Value.Address, kvp.Value.WorldData.Value).ConfigureAwait(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnterGpose()
|
||||
{
|
||||
ForceResendOwnData();
|
||||
ResetOwnData();
|
||||
foreach (var data in _usersInLobby.Values)
|
||||
{
|
||||
_ = _dalamudUtil.RunOnFrameworkThread(() => _vfxSpawnManager.DespawnObject(data.SpawnedVfxId));
|
||||
data.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnExitGpose()
|
||||
{
|
||||
ForceResendOwnData();
|
||||
ResetOwnData();
|
||||
foreach (var data in _usersInLobby.Values)
|
||||
{
|
||||
data.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private bool _forceResendFullPose = false;
|
||||
private bool _forceResendWorldData = false;
|
||||
|
||||
private void ForceResendOwnData()
|
||||
{
|
||||
_forceResendFullPose = true;
|
||||
_forceResendWorldData = true;
|
||||
}
|
||||
|
||||
private void ResetOwnData()
|
||||
{
|
||||
_poseGenerationExecutions = 0;
|
||||
_lastCreatedCharaData = null;
|
||||
}
|
||||
|
||||
private void OnFrameworkUpdate()
|
||||
{
|
||||
var frameworkTime = DateTime.UtcNow;
|
||||
foreach (var kvp in _usersInLobby)
|
||||
{
|
||||
if (kvp.Value.SpawnedVfxId != null && kvp.Value.UpdateStart != null)
|
||||
{
|
||||
var secondsElasped = frameworkTime.Subtract(kvp.Value.UpdateStart.Value).TotalSeconds;
|
||||
if (secondsElasped >= 1)
|
||||
{
|
||||
kvp.Value.LastWorldPosition = kvp.Value.TargetWorldPosition;
|
||||
kvp.Value.TargetWorldPosition = null;
|
||||
kvp.Value.UpdateStart = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
var lerp = Vector3.Lerp(kvp.Value.LastWorldPosition ?? Vector3.One, kvp.Value.TargetWorldPosition ?? Vector3.One, (float)secondsElasped);
|
||||
_vfxSpawnManager.MoveObject(kvp.Value.SpawnedVfxId.Value, lerp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnReceiveCharaData(CharaDataDownloadDto charaDataDownloadDto)
|
||||
{
|
||||
if (!_usersInLobby.TryGetValue(charaDataDownloadDto.Uploader.UID, out var lobbyData))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lobbyData.CharaData = charaDataDownloadDto;
|
||||
if (lobbyData.Address != nint.Zero && !string.IsNullOrEmpty(lobbyData.AssociatedCharaName))
|
||||
{
|
||||
_ = ApplyCharaData(lobbyData);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ApplyCharaData(GposeLobbyUserData userData)
|
||||
{
|
||||
if (userData.CharaData == null || userData.Address == nint.Zero || string.IsNullOrEmpty(userData.AssociatedCharaName))
|
||||
return;
|
||||
|
||||
await _charaDataCreationSemaphore.WaitAsync(_lobbyCts.Token).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
await _charaDataManager.ApplyCharaData(userData.CharaData!, userData.AssociatedCharaName).ConfigureAwait(false);
|
||||
userData.LastAppliedCharaDataDate = userData.CharaData.UpdatedDate;
|
||||
userData.HasPoseDataUpdate = true;
|
||||
userData.HasWorldDataUpdate = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_charaDataCreationSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private readonly SemaphoreSlim _charaDataSpawnSemaphore = new(1, 1);
|
||||
|
||||
internal async Task SpawnAndApplyData(GposeLobbyUserData userData)
|
||||
{
|
||||
if (userData.CharaData == null)
|
||||
return;
|
||||
|
||||
await _charaDataSpawnSemaphore.WaitAsync(_lobbyCts.Token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
userData.HasPoseDataUpdate = false;
|
||||
userData.HasWorldDataUpdate = false;
|
||||
var chara = await _charaDataManager.SpawnAndApplyData(userData.CharaData).ConfigureAwait(false);
|
||||
if (chara == null) return;
|
||||
userData.HandledChara = chara;
|
||||
userData.AssociatedCharaName = chara.Name;
|
||||
userData.HasPoseDataUpdate = true;
|
||||
userData.HasWorldDataUpdate = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_charaDataSpawnSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnReceivePoseData(UserData userData, PoseData poseData)
|
||||
{
|
||||
if (!_usersInLobby.TryGetValue(userData.UID, out var lobbyData))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (poseData.IsDelta)
|
||||
lobbyData.DeltaPoseData = poseData;
|
||||
else
|
||||
lobbyData.FullPoseData = poseData;
|
||||
}
|
||||
|
||||
private void OnReceiveWorldData(UserData userData, WorldData worldData)
|
||||
{
|
||||
_usersInLobby[userData.UID].WorldData = worldData;
|
||||
_ = _usersInLobby[userData.UID].SetWorldDataDescriptor(_dalamudUtil);
|
||||
}
|
||||
|
||||
private void OnUserJoinLobby(UserData userData)
|
||||
{
|
||||
if (_usersInLobby.ContainsKey(userData.UID))
|
||||
OnUserLeaveLobby(userData);
|
||||
_usersInLobby[userData.UID] = new(userData);
|
||||
_ = PushCharacterDownloadDto();
|
||||
}
|
||||
|
||||
private void OnUserLeaveLobby(UserData msg)
|
||||
{
|
||||
_usersInLobby.Remove(msg.UID, out var existingData);
|
||||
if (existingData != default)
|
||||
{
|
||||
_ = _dalamudUtil.RunOnFrameworkThread(() => _vfxSpawnManager.DespawnObject(existingData.SpawnedVfxId));
|
||||
}
|
||||
}
|
||||
}
|
1022
MareSynchronos/Services/CharaData/CharaDataManager.cs
Normal file
1022
MareSynchronos/Services/CharaData/CharaDataManager.cs
Normal file
File diff suppressed because it is too large
Load Diff
296
MareSynchronos/Services/CharaData/CharaDataNearbyManager.cs
Normal file
296
MareSynchronos/Services/CharaData/CharaDataNearbyManager.cs
Normal file
@@ -0,0 +1,296 @@
|
||||
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
||||
using MareSynchronos.API.Data;
|
||||
using MareSynchronos.Interop;
|
||||
using MareSynchronos.MareConfiguration;
|
||||
using MareSynchronos.Services.CharaData.Models;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using MareSynchronos.Services.ServerConfiguration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Numerics;
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
public sealed class CharaDataNearbyManager : DisposableMediatorSubscriberBase
|
||||
{
|
||||
public record NearbyCharaDataEntry
|
||||
{
|
||||
public float Direction { get; init; }
|
||||
public float Distance { get; init; }
|
||||
}
|
||||
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
private readonly Dictionary<PoseEntryExtended, NearbyCharaDataEntry> _nearbyData = [];
|
||||
private readonly Dictionary<PoseEntryExtended, Guid> _poseVfx = [];
|
||||
private readonly ServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly CharaDataConfigService _charaDataConfigService;
|
||||
private readonly Dictionary<UserData, List<CharaDataMetaInfoExtendedDto>> _metaInfoCache = [];
|
||||
private readonly VfxSpawnManager _vfxSpawnManager;
|
||||
private Task? _filterEntriesRunningTask;
|
||||
private (Guid VfxId, PoseEntryExtended Pose)? _hoveredVfx = null;
|
||||
private DateTime _lastExecutionTime = DateTime.UtcNow;
|
||||
private SemaphoreSlim _sharedDataUpdateSemaphore = new(1, 1);
|
||||
public CharaDataNearbyManager(ILogger<CharaDataNearbyManager> logger, MareMediator mediator,
|
||||
DalamudUtilService dalamudUtilService, VfxSpawnManager vfxSpawnManager,
|
||||
ServerConfigurationManager serverConfigurationManager,
|
||||
CharaDataConfigService charaDataConfigService) : base(logger, mediator)
|
||||
{
|
||||
mediator.Subscribe<FrameworkUpdateMessage>(this, (_) => HandleFrameworkUpdate());
|
||||
mediator.Subscribe<CutsceneFrameworkUpdateMessage>(this, (_) => HandleFrameworkUpdate());
|
||||
_dalamudUtilService = dalamudUtilService;
|
||||
_vfxSpawnManager = vfxSpawnManager;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_charaDataConfigService = charaDataConfigService;
|
||||
mediator.Subscribe<GposeStartMessage>(this, (_) => ClearAllVfx());
|
||||
}
|
||||
|
||||
public bool ComputeNearbyData { get; set; } = false;
|
||||
|
||||
public IDictionary<PoseEntryExtended, NearbyCharaDataEntry> NearbyData => _nearbyData;
|
||||
|
||||
public string UserNoteFilter { get; set; } = string.Empty;
|
||||
|
||||
public void UpdateSharedData(Dictionary<string, CharaDataMetaInfoExtendedDto?> newData)
|
||||
{
|
||||
_sharedDataUpdateSemaphore.Wait();
|
||||
try
|
||||
{
|
||||
_metaInfoCache.Clear();
|
||||
foreach (var kvp in newData)
|
||||
{
|
||||
if (kvp.Value == null) continue;
|
||||
|
||||
if (!_metaInfoCache.TryGetValue(kvp.Value.Uploader, out var list))
|
||||
{
|
||||
_metaInfoCache[kvp.Value.Uploader] = list = [];
|
||||
}
|
||||
|
||||
list.Add(kvp.Value);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sharedDataUpdateSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
internal void SetHoveredVfx(PoseEntryExtended? hoveredPose)
|
||||
{
|
||||
if (hoveredPose == null && _hoveredVfx == null)
|
||||
return;
|
||||
|
||||
if (hoveredPose == null)
|
||||
{
|
||||
_vfxSpawnManager.DespawnObject(_hoveredVfx!.Value.VfxId);
|
||||
_hoveredVfx = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_hoveredVfx == null)
|
||||
{
|
||||
var vfxGuid = _vfxSpawnManager.SpawnObject(hoveredPose.Position, hoveredPose.Rotation, Vector3.One * 4, 1, 0.2f, 0.2f, 1f);
|
||||
if (vfxGuid != null)
|
||||
_hoveredVfx = (vfxGuid.Value, hoveredPose);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hoveredPose != _hoveredVfx!.Value.Pose)
|
||||
{
|
||||
_vfxSpawnManager.DespawnObject(_hoveredVfx.Value.VfxId);
|
||||
var vfxGuid = _vfxSpawnManager.SpawnObject(hoveredPose.Position, hoveredPose.Rotation, Vector3.One * 4, 1, 0.2f, 0.2f, 1f);
|
||||
if (vfxGuid != null)
|
||||
_hoveredVfx = (vfxGuid.Value, hoveredPose);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
ClearAllVfx();
|
||||
}
|
||||
|
||||
private static float CalculateYawDegrees(Vector3 directionXZ)
|
||||
{
|
||||
// Calculate yaw angle in radians using Atan2 (X, Z)
|
||||
float yawRadians = (float)Math.Atan2(-directionXZ.X, directionXZ.Z);
|
||||
float yawDegrees = yawRadians * (180f / (float)Math.PI);
|
||||
|
||||
// Normalize to [0, 360)
|
||||
if (yawDegrees < 0)
|
||||
yawDegrees += 360f;
|
||||
|
||||
return yawDegrees;
|
||||
}
|
||||
|
||||
private static float GetAngleToTarget(Vector3 cameraPosition, float cameraYawDegrees, Vector3 targetPosition)
|
||||
{
|
||||
// Step 4: Calculate the direction vector from camera to target
|
||||
Vector3 directionToTarget = targetPosition - cameraPosition;
|
||||
|
||||
// Step 5: Project the directionToTarget onto the XZ plane (ignore Y)
|
||||
Vector3 directionToTargetXZ = new Vector3(directionToTarget.X, 0, directionToTarget.Z);
|
||||
|
||||
// Handle the case where the target is directly above or below the camera
|
||||
if (directionToTargetXZ.LengthSquared() < 1e-10f)
|
||||
{
|
||||
return 0; // Default direction
|
||||
}
|
||||
|
||||
directionToTargetXZ = Vector3.Normalize(directionToTargetXZ);
|
||||
|
||||
// Step 6: Calculate the target's yaw angle
|
||||
float targetYawDegrees = CalculateYawDegrees(directionToTargetXZ);
|
||||
|
||||
// Step 7: Calculate relative angle
|
||||
float relativeAngle = targetYawDegrees - cameraYawDegrees;
|
||||
if (relativeAngle < 0)
|
||||
relativeAngle += 360f;
|
||||
|
||||
// Step 8: Map relative angle to ArrowDirection
|
||||
return relativeAngle;
|
||||
}
|
||||
|
||||
private static float GetCameraYaw(Vector3 cameraPosition, Vector3 lookAtVector)
|
||||
{
|
||||
// Step 1: Calculate the direction vector from camera to LookAtPoint
|
||||
Vector3 directionFacing = lookAtVector - cameraPosition;
|
||||
|
||||
// Step 2: Project the directionFacing onto the XZ plane (ignore Y)
|
||||
Vector3 directionFacingXZ = new Vector3(directionFacing.X, 0, directionFacing.Z);
|
||||
|
||||
// Handle the case where the LookAtPoint is directly above or below the camera
|
||||
if (directionFacingXZ.LengthSquared() < 1e-10f)
|
||||
{
|
||||
// Default to facing forward along the Z-axis if LookAtPoint is directly above or below
|
||||
directionFacingXZ = new Vector3(0, 0, 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
directionFacingXZ = Vector3.Normalize(directionFacingXZ);
|
||||
}
|
||||
|
||||
// Step 3: Calculate the camera's yaw angle based on directionFacingXZ
|
||||
return (CalculateYawDegrees(directionFacingXZ));
|
||||
}
|
||||
|
||||
private void ClearAllVfx()
|
||||
{
|
||||
foreach (var vfx in _poseVfx)
|
||||
{
|
||||
_vfxSpawnManager.DespawnObject(vfx.Value);
|
||||
}
|
||||
_poseVfx.Clear();
|
||||
}
|
||||
|
||||
private async Task FilterEntriesAsync(Vector3 cameraPos, Vector3 cameraLookAt)
|
||||
{
|
||||
var previousPoses = _nearbyData.Keys.ToList();
|
||||
_nearbyData.Clear();
|
||||
|
||||
var ownLocation = await _dalamudUtilService.RunOnFrameworkThread(() => _dalamudUtilService.GetMapData()).ConfigureAwait(false);
|
||||
var player = await _dalamudUtilService.RunOnFrameworkThread(() => _dalamudUtilService.GetPlayerCharacter()).ConfigureAwait(false);
|
||||
var currentServer = player.CurrentWorld;
|
||||
var playerPos = player.Position;
|
||||
|
||||
var cameraYaw = GetCameraYaw(cameraPos, cameraLookAt);
|
||||
|
||||
bool ignoreHousingLimits = _charaDataConfigService.Current.NearbyIgnoreHousingLimitations;
|
||||
bool onlyCurrentServer = _charaDataConfigService.Current.NearbyOwnServerOnly;
|
||||
bool showOwnData = _charaDataConfigService.Current.NearbyShowOwnData;
|
||||
|
||||
// initial filter on name
|
||||
foreach (var data in _metaInfoCache.Where(d => (string.IsNullOrWhiteSpace(UserNoteFilter)
|
||||
|| ((d.Key.Alias ?? string.Empty).Contains(UserNoteFilter, StringComparison.OrdinalIgnoreCase)
|
||||
|| d.Key.UID.Contains(UserNoteFilter, StringComparison.OrdinalIgnoreCase)
|
||||
|| (_serverConfigurationManager.GetNoteForUid(UserNoteFilter) ?? string.Empty).Contains(UserNoteFilter, StringComparison.OrdinalIgnoreCase))))
|
||||
.ToDictionary(k => k.Key, k => k.Value))
|
||||
{
|
||||
// filter all poses based on territory, that always must be correct
|
||||
foreach (var pose in data.Value.Where(v => v.HasPoses && v.HasWorldData && (showOwnData || !v.IsOwnData))
|
||||
.SelectMany(k => k.PoseExtended)
|
||||
.Where(p => p.HasPoseData
|
||||
&& p.HasWorldData
|
||||
&& p.WorldData!.Value.LocationInfo.TerritoryId == ownLocation.TerritoryId)
|
||||
.ToList())
|
||||
{
|
||||
var poseLocation = pose.WorldData!.Value.LocationInfo;
|
||||
|
||||
bool isInHousing = poseLocation.WardId != 0;
|
||||
var distance = Vector3.Distance(playerPos, pose.Position);
|
||||
if (distance > _charaDataConfigService.Current.NearbyDistanceFilter) continue;
|
||||
|
||||
|
||||
bool addEntry = (!isInHousing && poseLocation.MapId == ownLocation.MapId
|
||||
&& (!onlyCurrentServer || poseLocation.ServerId == currentServer.RowId))
|
||||
|| (isInHousing
|
||||
&& (((ignoreHousingLimits && !onlyCurrentServer)
|
||||
|| (ignoreHousingLimits && onlyCurrentServer) && poseLocation.ServerId == currentServer.RowId)
|
||||
|| poseLocation.ServerId == currentServer.RowId)
|
||||
&& ((poseLocation.HouseId == 0 && poseLocation.DivisionId == ownLocation.DivisionId
|
||||
&& (ignoreHousingLimits || poseLocation.WardId == ownLocation.WardId))
|
||||
|| (poseLocation.HouseId > 0
|
||||
&& (ignoreHousingLimits || (poseLocation.HouseId == ownLocation.HouseId && poseLocation.WardId == ownLocation.WardId && poseLocation.DivisionId == ownLocation.DivisionId && poseLocation.RoomId == ownLocation.RoomId)))
|
||||
));
|
||||
|
||||
if (addEntry)
|
||||
_nearbyData[pose] = new() { Direction = GetAngleToTarget(cameraPos, cameraYaw, pose.Position), Distance = distance };
|
||||
}
|
||||
}
|
||||
|
||||
if (_charaDataConfigService.Current.NearbyDrawWisps && !_dalamudUtilService.IsInGpose && !_dalamudUtilService.IsInCombatOrPerforming)
|
||||
await _dalamudUtilService.RunOnFrameworkThread(() => ManageWispsNearby(previousPoses)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private unsafe void HandleFrameworkUpdate()
|
||||
{
|
||||
if (_lastExecutionTime.AddSeconds(0.5) > DateTime.UtcNow) return;
|
||||
_lastExecutionTime = DateTime.UtcNow;
|
||||
if (!ComputeNearbyData && !_charaDataConfigService.Current.NearbyShowAlways)
|
||||
{
|
||||
if (_nearbyData.Any())
|
||||
_nearbyData.Clear();
|
||||
if (_poseVfx.Any())
|
||||
ClearAllVfx();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_charaDataConfigService.Current.NearbyDrawWisps || _dalamudUtilService.IsInGpose || _dalamudUtilService.IsInCombatOrPerforming)
|
||||
ClearAllVfx();
|
||||
|
||||
var camera = CameraManager.Instance()->CurrentCamera;
|
||||
Vector3 cameraPos = new(camera->Position.X, camera->Position.Y, camera->Position.Z);
|
||||
Vector3 lookAt = new(camera->LookAtVector.X, camera->LookAtVector.Y, camera->LookAtVector.Z);
|
||||
|
||||
if (_filterEntriesRunningTask?.IsCompleted ?? true && _dalamudUtilService.IsLoggedIn)
|
||||
_filterEntriesRunningTask = FilterEntriesAsync(cameraPos, lookAt);
|
||||
}
|
||||
|
||||
private void ManageWispsNearby(List<PoseEntryExtended> previousPoses)
|
||||
{
|
||||
foreach (var data in _nearbyData.Keys)
|
||||
{
|
||||
if (_poseVfx.TryGetValue(data, out var _)) continue;
|
||||
|
||||
Guid? vfxGuid;
|
||||
if (data.MetaInfo.IsOwnData)
|
||||
{
|
||||
vfxGuid = _vfxSpawnManager.SpawnObject(data.Position, data.Rotation, Vector3.One * 2, 0.8f, 0.5f, 0.0f, 0.7f);
|
||||
}
|
||||
else
|
||||
{
|
||||
vfxGuid = _vfxSpawnManager.SpawnObject(data.Position, data.Rotation, Vector3.One * 2);
|
||||
}
|
||||
if (vfxGuid != null)
|
||||
{
|
||||
_poseVfx[data] = vfxGuid.Value;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var data in previousPoses.Except(_nearbyData.Keys))
|
||||
{
|
||||
if (_poseVfx.Remove(data, out var guid))
|
||||
{
|
||||
_vfxSpawnManager.DespawnObject(guid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,20 @@
|
||||
using MareSynchronos.API.Data;
|
||||
using MareSynchronos.FileCache;
|
||||
using MareSynchronos.Services.CharaData.Models;
|
||||
|
||||
namespace MareSynchronos.Services.CharaData;
|
||||
|
||||
public sealed class MareCharaFileDataFactory
|
||||
{
|
||||
private readonly FileCacheManager _fileCacheManager;
|
||||
|
||||
public MareCharaFileDataFactory(FileCacheManager fileCacheManager)
|
||||
{
|
||||
_fileCacheManager = fileCacheManager;
|
||||
}
|
||||
|
||||
public MareCharaFileData Create(string description, CharacterData characterCacheDto)
|
||||
{
|
||||
return new MareCharaFileData(_fileCacheManager, description, characterCacheDto);
|
||||
}
|
||||
}
|
@@ -0,0 +1,362 @@
|
||||
using MareSynchronos.API.Data;
|
||||
using MareSynchronos.API.Dto.CharaData;
|
||||
|
||||
namespace MareSynchronos.Services.CharaData.Models;
|
||||
|
||||
public sealed record CharaDataExtendedUpdateDto : CharaDataUpdateDto
|
||||
{
|
||||
private readonly CharaDataFullDto _charaDataFullDto;
|
||||
|
||||
public CharaDataExtendedUpdateDto(CharaDataUpdateDto dto, CharaDataFullDto charaDataFullDto) : base(dto)
|
||||
{
|
||||
_charaDataFullDto = charaDataFullDto;
|
||||
_userList = charaDataFullDto.AllowedUsers.ToList();
|
||||
_groupList = charaDataFullDto.AllowedGroups.ToList();
|
||||
_poseList = charaDataFullDto.PoseData.Select(k => new PoseEntry(k.Id)
|
||||
{
|
||||
Description = k.Description,
|
||||
PoseData = k.PoseData,
|
||||
WorldData = k.WorldData
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public CharaDataUpdateDto BaseDto => new(Id)
|
||||
{
|
||||
AllowedUsers = AllowedUsers,
|
||||
AllowedGroups = AllowedGroups,
|
||||
AccessType = base.AccessType,
|
||||
CustomizeData = base.CustomizeData,
|
||||
Description = base.Description,
|
||||
ExpiryDate = base.ExpiryDate,
|
||||
FileGamePaths = base.FileGamePaths,
|
||||
FileSwaps = base.FileSwaps,
|
||||
GlamourerData = base.GlamourerData,
|
||||
ShareType = base.ShareType,
|
||||
ManipulationData = base.ManipulationData,
|
||||
Poses = Poses
|
||||
};
|
||||
|
||||
public new string ManipulationData
|
||||
{
|
||||
get
|
||||
{
|
||||
return base.ManipulationData ?? _charaDataFullDto.ManipulationData;
|
||||
}
|
||||
set
|
||||
{
|
||||
base.ManipulationData = value;
|
||||
if (string.Equals(base.ManipulationData, _charaDataFullDto.ManipulationData, StringComparison.Ordinal))
|
||||
{
|
||||
base.ManipulationData = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public new string Description
|
||||
{
|
||||
get
|
||||
{
|
||||
return base.Description ?? _charaDataFullDto.Description;
|
||||
}
|
||||
set
|
||||
{
|
||||
base.Description = value;
|
||||
if (string.Equals(base.Description, _charaDataFullDto.Description, StringComparison.Ordinal))
|
||||
{
|
||||
base.Description = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public new DateTime ExpiryDate
|
||||
{
|
||||
get
|
||||
{
|
||||
return base.ExpiryDate ?? _charaDataFullDto.ExpiryDate;
|
||||
}
|
||||
private set
|
||||
{
|
||||
base.ExpiryDate = value;
|
||||
if (Equals(base.ExpiryDate, _charaDataFullDto.ExpiryDate))
|
||||
{
|
||||
base.ExpiryDate = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public new AccessTypeDto AccessType
|
||||
{
|
||||
get
|
||||
{
|
||||
return base.AccessType ?? _charaDataFullDto.AccessType;
|
||||
}
|
||||
set
|
||||
{
|
||||
base.AccessType = value;
|
||||
if (AccessType == AccessTypeDto.Public && ShareType == ShareTypeDto.Shared)
|
||||
{
|
||||
ShareType = ShareTypeDto.Private;
|
||||
}
|
||||
|
||||
if (Equals(base.AccessType, _charaDataFullDto.AccessType))
|
||||
{
|
||||
base.AccessType = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public new ShareTypeDto ShareType
|
||||
{
|
||||
get
|
||||
{
|
||||
return base.ShareType ?? _charaDataFullDto.ShareType;
|
||||
}
|
||||
set
|
||||
{
|
||||
base.ShareType = value;
|
||||
if (ShareType == ShareTypeDto.Shared && AccessType == AccessTypeDto.Public)
|
||||
{
|
||||
base.ShareType = ShareTypeDto.Private;
|
||||
}
|
||||
|
||||
if (Equals(base.ShareType, _charaDataFullDto.ShareType))
|
||||
{
|
||||
base.ShareType = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public new List<GamePathEntry>? FileGamePaths
|
||||
{
|
||||
get
|
||||
{
|
||||
return base.FileGamePaths ?? _charaDataFullDto.FileGamePaths;
|
||||
}
|
||||
set
|
||||
{
|
||||
base.FileGamePaths = value;
|
||||
if (!(base.FileGamePaths ?? []).Except(_charaDataFullDto.FileGamePaths).Any()
|
||||
&& !_charaDataFullDto.FileGamePaths.Except(base.FileGamePaths ?? []).Any())
|
||||
{
|
||||
base.FileGamePaths = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public new List<GamePathEntry>? FileSwaps
|
||||
{
|
||||
get
|
||||
{
|
||||
return base.FileSwaps ?? _charaDataFullDto.FileSwaps;
|
||||
}
|
||||
set
|
||||
{
|
||||
base.FileSwaps = value;
|
||||
if (!(base.FileSwaps ?? []).Except(_charaDataFullDto.FileSwaps).Any()
|
||||
&& !_charaDataFullDto.FileSwaps.Except(base.FileSwaps ?? []).Any())
|
||||
{
|
||||
base.FileSwaps = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public new string? GlamourerData
|
||||
{
|
||||
get
|
||||
{
|
||||
return base.GlamourerData ?? _charaDataFullDto.GlamourerData;
|
||||
}
|
||||
set
|
||||
{
|
||||
base.GlamourerData = value;
|
||||
if (string.Equals(base.GlamourerData, _charaDataFullDto.GlamourerData, StringComparison.Ordinal))
|
||||
{
|
||||
base.GlamourerData = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public new string? CustomizeData
|
||||
{
|
||||
get
|
||||
{
|
||||
return base.CustomizeData ?? _charaDataFullDto.CustomizeData;
|
||||
}
|
||||
set
|
||||
{
|
||||
base.CustomizeData = value;
|
||||
if (string.Equals(base.CustomizeData, _charaDataFullDto.CustomizeData, StringComparison.Ordinal))
|
||||
{
|
||||
base.CustomizeData = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<UserData> UserList => _userList;
|
||||
private readonly List<UserData> _userList;
|
||||
|
||||
public IEnumerable<GroupData> GroupList => _groupList;
|
||||
private readonly List<GroupData> _groupList;
|
||||
|
||||
public IEnumerable<PoseEntry> PoseList => _poseList;
|
||||
private readonly List<PoseEntry> _poseList;
|
||||
|
||||
public void AddUserToList(string user)
|
||||
{
|
||||
_userList.Add(new(user, null));
|
||||
UpdateAllowedUsers();
|
||||
}
|
||||
|
||||
public void AddGroupToList(string group)
|
||||
{
|
||||
_groupList.Add(new(group, null));
|
||||
UpdateAllowedGroups();
|
||||
}
|
||||
|
||||
private void UpdateAllowedUsers()
|
||||
{
|
||||
AllowedUsers = [.. _userList.Select(u => u.UID)];
|
||||
if (!AllowedUsers.Except(_charaDataFullDto.AllowedUsers.Select(u => u.UID), StringComparer.Ordinal).Any()
|
||||
&& !_charaDataFullDto.AllowedUsers.Select(u => u.UID).Except(AllowedUsers, StringComparer.Ordinal).Any())
|
||||
{
|
||||
AllowedUsers = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateAllowedGroups()
|
||||
{
|
||||
AllowedGroups = [.. _groupList.Select(u => u.GID)];
|
||||
if (!AllowedGroups.Except(_charaDataFullDto.AllowedGroups.Select(u => u.GID), StringComparer.Ordinal).Any()
|
||||
&& !_charaDataFullDto.AllowedGroups.Select(u => u.GID).Except(AllowedGroups, StringComparer.Ordinal).Any())
|
||||
{
|
||||
AllowedGroups = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveUserFromList(string user)
|
||||
{
|
||||
_userList.RemoveAll(u => string.Equals(u.UID, user, StringComparison.Ordinal));
|
||||
UpdateAllowedUsers();
|
||||
}
|
||||
|
||||
public void RemoveGroupFromList(string group)
|
||||
{
|
||||
_groupList.RemoveAll(u => string.Equals(u.GID, group, StringComparison.Ordinal));
|
||||
UpdateAllowedGroups();
|
||||
}
|
||||
|
||||
public void AddPose()
|
||||
{
|
||||
_poseList.Add(new PoseEntry(null));
|
||||
UpdatePoseList();
|
||||
}
|
||||
|
||||
public void RemovePose(PoseEntry entry)
|
||||
{
|
||||
if (entry.Id != null)
|
||||
{
|
||||
entry.Description = null;
|
||||
entry.WorldData = null;
|
||||
entry.PoseData = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_poseList.Remove(entry);
|
||||
}
|
||||
|
||||
UpdatePoseList();
|
||||
}
|
||||
|
||||
public void UpdatePoseList()
|
||||
{
|
||||
Poses = [.. _poseList];
|
||||
if (!Poses.Except(_charaDataFullDto.PoseData).Any() && !_charaDataFullDto.PoseData.Except(Poses).Any())
|
||||
{
|
||||
Poses = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetExpiry(bool expiring)
|
||||
{
|
||||
if (expiring)
|
||||
{
|
||||
var date = DateTime.UtcNow.AddDays(7);
|
||||
SetExpiry(date.Year, date.Month, date.Day);
|
||||
}
|
||||
else
|
||||
{
|
||||
ExpiryDate = DateTime.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetExpiry(int year, int month, int day)
|
||||
{
|
||||
int daysInMonth = DateTime.DaysInMonth(year, month);
|
||||
if (day > daysInMonth) day = 1;
|
||||
ExpiryDate = new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
internal void UndoChanges()
|
||||
{
|
||||
base.Description = null;
|
||||
base.AccessType = null;
|
||||
base.ShareType = null;
|
||||
base.GlamourerData = null;
|
||||
base.FileSwaps = null;
|
||||
base.FileGamePaths = null;
|
||||
base.CustomizeData = null;
|
||||
base.ManipulationData = null;
|
||||
AllowedUsers = null;
|
||||
AllowedGroups = null;
|
||||
Poses = null;
|
||||
_poseList.Clear();
|
||||
_poseList.AddRange(_charaDataFullDto.PoseData.Select(k => new PoseEntry(k.Id)
|
||||
{
|
||||
Description = k.Description,
|
||||
PoseData = k.PoseData,
|
||||
WorldData = k.WorldData
|
||||
}));
|
||||
}
|
||||
|
||||
internal void RevertDeletion(PoseEntry pose)
|
||||
{
|
||||
if (pose.Id == null) return;
|
||||
var oldPose = _charaDataFullDto.PoseData.Find(p => p.Id == pose.Id);
|
||||
if (oldPose == null) return;
|
||||
pose.Description = oldPose.Description;
|
||||
pose.PoseData = oldPose.PoseData;
|
||||
pose.WorldData = oldPose.WorldData;
|
||||
UpdatePoseList();
|
||||
}
|
||||
|
||||
internal bool PoseHasChanges(PoseEntry pose)
|
||||
{
|
||||
if (pose.Id == null) return false;
|
||||
var oldPose = _charaDataFullDto.PoseData.Find(p => p.Id == pose.Id);
|
||||
if (oldPose == null) return false;
|
||||
return !string.Equals(pose.Description, oldPose.Description, StringComparison.Ordinal)
|
||||
|| !string.Equals(pose.PoseData, oldPose.PoseData, StringComparison.Ordinal)
|
||||
|| pose.WorldData != oldPose.WorldData;
|
||||
}
|
||||
|
||||
public bool HasChanges =>
|
||||
base.Description != null
|
||||
|| base.ExpiryDate != null
|
||||
|| base.AccessType != null
|
||||
|| base.ShareType != null
|
||||
|| AllowedUsers != null
|
||||
|| AllowedGroups != null
|
||||
|| base.GlamourerData != null
|
||||
|| base.FileSwaps != null
|
||||
|| base.FileGamePaths != null
|
||||
|| base.CustomizeData != null
|
||||
|| base.ManipulationData != null
|
||||
|| Poses != null;
|
||||
|
||||
public bool IsAppearanceEqual =>
|
||||
string.Equals(GlamourerData, _charaDataFullDto.GlamourerData, StringComparison.Ordinal)
|
||||
&& string.Equals(CustomizeData, _charaDataFullDto.CustomizeData, StringComparison.Ordinal)
|
||||
&& FileGamePaths == _charaDataFullDto.FileGamePaths
|
||||
&& FileSwaps == _charaDataFullDto.FileSwaps
|
||||
&& string.Equals(ManipulationData, _charaDataFullDto.ManipulationData, StringComparison.Ordinal);
|
||||
}
|
@@ -0,0 +1,18 @@
|
||||
using MareSynchronos.API.Dto.CharaData;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace MareSynchronos.Services.CharaData.Models;
|
||||
|
||||
public sealed record CharaDataFullExtendedDto : CharaDataFullDto
|
||||
{
|
||||
public CharaDataFullExtendedDto(CharaDataFullDto baseDto) : base(baseDto)
|
||||
{
|
||||
FullId = baseDto.Uploader.UID + ":" + baseDto.Id;
|
||||
MissingFiles = new ReadOnlyCollection<GamePathEntry>(baseDto.OriginalFiles.Except(baseDto.FileGamePaths).ToList());
|
||||
HasMissingFiles = MissingFiles.Any();
|
||||
}
|
||||
|
||||
public string FullId { get; set; }
|
||||
public bool HasMissingFiles { get; init; }
|
||||
public IReadOnlyCollection<GamePathEntry> MissingFiles { get; init; }
|
||||
}
|
@@ -0,0 +1,31 @@
|
||||
using MareSynchronos.API.Dto.CharaData;
|
||||
|
||||
namespace MareSynchronos.Services.CharaData.Models;
|
||||
|
||||
public sealed record CharaDataMetaInfoExtendedDto : CharaDataMetaInfoDto
|
||||
{
|
||||
private CharaDataMetaInfoExtendedDto(CharaDataMetaInfoDto baseMeta) : base(baseMeta)
|
||||
{
|
||||
FullId = baseMeta.Uploader.UID + ":" + baseMeta.Id;
|
||||
}
|
||||
|
||||
public List<PoseEntryExtended> PoseExtended { get; private set; } = [];
|
||||
public bool HasPoses => PoseExtended.Count != 0;
|
||||
public bool HasWorldData => PoseExtended.Exists(p => p.HasWorldData);
|
||||
public bool IsOwnData { get; private set; }
|
||||
public string FullId { get; private set; }
|
||||
|
||||
public async static Task<CharaDataMetaInfoExtendedDto> Create(CharaDataMetaInfoDto baseMeta, DalamudUtilService dalamudUtilService, bool isOwnData = false)
|
||||
{
|
||||
CharaDataMetaInfoExtendedDto newDto = new(baseMeta);
|
||||
|
||||
foreach (var pose in newDto.PoseData)
|
||||
{
|
||||
newDto.PoseExtended.Add(await PoseEntryExtended.Create(pose, newDto, dalamudUtilService).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
newDto.IsOwnData = isOwnData;
|
||||
|
||||
return newDto;
|
||||
}
|
||||
}
|
174
MareSynchronos/Services/CharaData/Models/GposeLobbyUserData.cs
Normal file
174
MareSynchronos/Services/CharaData/Models/GposeLobbyUserData.cs
Normal file
@@ -0,0 +1,174 @@
|
||||
using Dalamud.Utility;
|
||||
using MareSynchronos.API.Data;
|
||||
using MareSynchronos.API.Dto.CharaData;
|
||||
using MareSynchronos.Utils;
|
||||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
|
||||
namespace MareSynchronos.Services.CharaData.Models;
|
||||
|
||||
public sealed record GposeLobbyUserData(UserData UserData)
|
||||
{
|
||||
public void Reset()
|
||||
{
|
||||
HasWorldDataUpdate = WorldData != null;
|
||||
HasPoseDataUpdate = ApplicablePoseData != null;
|
||||
SpawnedVfxId = null;
|
||||
LastAppliedCharaDataDate = DateTime.MinValue;
|
||||
}
|
||||
|
||||
private WorldData? _worldData;
|
||||
public WorldData? WorldData
|
||||
{
|
||||
get => _worldData; set
|
||||
{
|
||||
_worldData = value;
|
||||
HasWorldDataUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasWorldDataUpdate { get; set; } = false;
|
||||
|
||||
private PoseData? _fullPoseData;
|
||||
private PoseData? _deltaPoseData;
|
||||
|
||||
public PoseData? FullPoseData
|
||||
{
|
||||
get => _fullPoseData;
|
||||
set
|
||||
{
|
||||
_fullPoseData = value;
|
||||
ApplicablePoseData = CombinePoseData();
|
||||
HasPoseDataUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
public PoseData? DeltaPoseData
|
||||
{
|
||||
get => _deltaPoseData;
|
||||
set
|
||||
{
|
||||
_deltaPoseData = value;
|
||||
ApplicablePoseData = CombinePoseData();
|
||||
HasPoseDataUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
public PoseData? ApplicablePoseData { get; private set; }
|
||||
public bool HasPoseDataUpdate { get; set; } = false;
|
||||
public Guid? SpawnedVfxId { get; set; }
|
||||
public Vector3? LastWorldPosition { get; set; }
|
||||
public Vector3? TargetWorldPosition { get; set; }
|
||||
public DateTime? UpdateStart { get; set; }
|
||||
private CharaDataDownloadDto? _charaData;
|
||||
public CharaDataDownloadDto? CharaData
|
||||
{
|
||||
get => _charaData; set
|
||||
{
|
||||
_charaData = value;
|
||||
LastUpdatedCharaData = _charaData?.UpdatedDate ?? DateTime.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
public DateTime LastUpdatedCharaData { get; private set; } = DateTime.MaxValue;
|
||||
public DateTime LastAppliedCharaDataDate { get; set; } = DateTime.MinValue;
|
||||
public nint Address { get; set; }
|
||||
public string AssociatedCharaName { get; set; } = string.Empty;
|
||||
|
||||
private PoseData? CombinePoseData()
|
||||
{
|
||||
if (DeltaPoseData == null && FullPoseData != null) return FullPoseData;
|
||||
if (FullPoseData == null) return null;
|
||||
|
||||
PoseData output = FullPoseData!.Value.DeepClone();
|
||||
PoseData delta = DeltaPoseData!.Value;
|
||||
|
||||
foreach (var bone in FullPoseData!.Value.Bones)
|
||||
{
|
||||
if (!delta.Bones.TryGetValue(bone.Key, out var data)) continue;
|
||||
if (!data.Exists)
|
||||
{
|
||||
output.Bones.Remove(bone.Key);
|
||||
}
|
||||
else
|
||||
{
|
||||
output.Bones[bone.Key] = data;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var bone in FullPoseData!.Value.MainHand)
|
||||
{
|
||||
if (!delta.MainHand.TryGetValue(bone.Key, out var data)) continue;
|
||||
if (!data.Exists)
|
||||
{
|
||||
output.MainHand.Remove(bone.Key);
|
||||
}
|
||||
else
|
||||
{
|
||||
output.MainHand[bone.Key] = data;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var bone in FullPoseData!.Value.OffHand)
|
||||
{
|
||||
if (!delta.OffHand.TryGetValue(bone.Key, out var data)) continue;
|
||||
if (!data.Exists)
|
||||
{
|
||||
output.OffHand.Remove(bone.Key);
|
||||
}
|
||||
else
|
||||
{
|
||||
output.OffHand[bone.Key] = data;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
public string WorldDataDescriptor { get; private set; } = string.Empty;
|
||||
public Vector2 MapCoordinates { get; private set; }
|
||||
public Lumina.Excel.Sheets.Map Map { get; private set; }
|
||||
public HandledCharaDataEntry? HandledChara { get; set; }
|
||||
|
||||
public async Task SetWorldDataDescriptor(DalamudUtilService dalamudUtilService)
|
||||
{
|
||||
if (WorldData == null)
|
||||
{
|
||||
WorldDataDescriptor = "No World Data found";
|
||||
}
|
||||
|
||||
var worldData = WorldData!.Value;
|
||||
MapCoordinates = await dalamudUtilService.RunOnFrameworkThread(() =>
|
||||
MapUtil.WorldToMap(new Vector2(worldData.PositionX, worldData.PositionY), dalamudUtilService.MapData.Value[worldData.LocationInfo.MapId].Map))
|
||||
.ConfigureAwait(false);
|
||||
Map = dalamudUtilService.MapData.Value[worldData.LocationInfo.MapId].Map;
|
||||
|
||||
StringBuilder sb = new();
|
||||
sb.AppendLine("Server: " + dalamudUtilService.WorldData.Value[(ushort)worldData.LocationInfo.ServerId]);
|
||||
sb.AppendLine("Territory: " + dalamudUtilService.TerritoryData.Value[worldData.LocationInfo.TerritoryId]);
|
||||
sb.AppendLine("Map: " + dalamudUtilService.MapData.Value[worldData.LocationInfo.MapId].MapName);
|
||||
|
||||
if (worldData.LocationInfo.WardId != 0)
|
||||
sb.AppendLine("Ward #: " + worldData.LocationInfo.WardId);
|
||||
if (worldData.LocationInfo.DivisionId != 0)
|
||||
{
|
||||
sb.AppendLine("Subdivision: " + worldData.LocationInfo.DivisionId switch
|
||||
{
|
||||
1 => "No",
|
||||
2 => "Yes",
|
||||
_ => "-"
|
||||
});
|
||||
}
|
||||
if (worldData.LocationInfo.HouseId != 0)
|
||||
{
|
||||
sb.AppendLine("House #: " + (worldData.LocationInfo.HouseId == 100 ? "Apartments" : worldData.LocationInfo.HouseId.ToString()));
|
||||
}
|
||||
if (worldData.LocationInfo.RoomId != 0)
|
||||
{
|
||||
sb.AppendLine("Apartment #: " + worldData.LocationInfo.RoomId);
|
||||
}
|
||||
sb.AppendLine("Coordinates: X: " + MapCoordinates.X.ToString("0.0", CultureInfo.InvariantCulture) + ", Y: " + MapCoordinates.Y.ToString("0.0", CultureInfo.InvariantCulture));
|
||||
WorldDataDescriptor = sb.ToString();
|
||||
}
|
||||
}
|
@@ -0,0 +1,6 @@
|
||||
namespace MareSynchronos.Services.CharaData.Models;
|
||||
|
||||
public sealed record HandledCharaDataEntry(string Name, bool IsSelf, Guid? CustomizePlus, CharaDataMetaInfoExtendedDto MetaInfo)
|
||||
{
|
||||
public CharaDataMetaInfoExtendedDto MetaInfo { get; set; } = MetaInfo;
|
||||
}
|
@@ -0,0 +1,70 @@
|
||||
using MareSynchronos.API.Data;
|
||||
using MareSynchronos.API.Data.Enum;
|
||||
using MareSynchronos.FileCache;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MareSynchronos.Services.CharaData.Models;
|
||||
|
||||
public record MareCharaFileData
|
||||
{
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string GlamourerData { get; set; } = string.Empty;
|
||||
public string CustomizePlusData { get; set; } = string.Empty;
|
||||
public string ManipulationData { get; set; } = string.Empty;
|
||||
public List<FileData> Files { get; set; } = [];
|
||||
public List<FileSwap> FileSwaps { get; set; } = [];
|
||||
|
||||
public MareCharaFileData() { }
|
||||
public MareCharaFileData(FileCacheManager manager, string description, CharacterData dto)
|
||||
{
|
||||
Description = description;
|
||||
|
||||
if (dto.GlamourerData.TryGetValue(ObjectKind.Player, out var glamourerData))
|
||||
{
|
||||
GlamourerData = glamourerData;
|
||||
}
|
||||
|
||||
dto.CustomizePlusData.TryGetValue(ObjectKind.Player, out var customizePlusData);
|
||||
CustomizePlusData = customizePlusData ?? string.Empty;
|
||||
ManipulationData = dto.ManipulationData;
|
||||
|
||||
if (dto.FileReplacements.TryGetValue(ObjectKind.Player, out var fileReplacements))
|
||||
{
|
||||
var grouped = fileReplacements.GroupBy(f => f.Hash, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var file in grouped)
|
||||
{
|
||||
if (string.IsNullOrEmpty(file.Key))
|
||||
{
|
||||
foreach (var item in file)
|
||||
{
|
||||
FileSwaps.Add(new FileSwap(item.GamePaths, item.FileSwapPath));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var filePath = manager.GetFileCacheByHash(file.First().Hash)?.ResolvedFilepath;
|
||||
if (filePath != null)
|
||||
{
|
||||
Files.Add(new FileData(file.SelectMany(f => f.GamePaths), (int)new FileInfo(filePath).Length, file.First().Hash));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] ToByteArray()
|
||||
{
|
||||
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(this));
|
||||
}
|
||||
|
||||
public static MareCharaFileData FromByteArray(byte[] data)
|
||||
{
|
||||
return JsonSerializer.Deserialize<MareCharaFileData>(Encoding.UTF8.GetString(data))!;
|
||||
}
|
||||
|
||||
public record FileSwap(IEnumerable<string> GamePaths, string FileSwapPath);
|
||||
|
||||
public record FileData(IEnumerable<string> GamePaths, int Length, string Hash);
|
||||
}
|
@@ -0,0 +1,54 @@
|
||||
namespace MareSynchronos.Services.CharaData.Models;
|
||||
|
||||
public record MareCharaFileHeader(byte Version, MareCharaFileData CharaFileData)
|
||||
{
|
||||
public static readonly byte CurrentVersion = 1;
|
||||
|
||||
public byte Version { get; set; } = Version;
|
||||
public MareCharaFileData CharaFileData { get; set; } = CharaFileData;
|
||||
public string FilePath { get; private set; } = string.Empty;
|
||||
|
||||
public void WriteToStream(BinaryWriter writer)
|
||||
{
|
||||
writer.Write('M');
|
||||
writer.Write('C');
|
||||
writer.Write('D');
|
||||
writer.Write('F');
|
||||
writer.Write(Version);
|
||||
var charaFileDataArray = CharaFileData.ToByteArray();
|
||||
writer.Write(charaFileDataArray.Length);
|
||||
writer.Write(charaFileDataArray);
|
||||
}
|
||||
|
||||
public static MareCharaFileHeader? FromBinaryReader(string path, BinaryReader reader)
|
||||
{
|
||||
var chars = new string(reader.ReadChars(4));
|
||||
if (!string.Equals(chars, "MCDF", StringComparison.Ordinal)) throw new InvalidDataException("Not a Mare Chara File");
|
||||
|
||||
MareCharaFileHeader? decoded = null;
|
||||
|
||||
var version = reader.ReadByte();
|
||||
if (version == 1)
|
||||
{
|
||||
var dataLength = reader.ReadInt32();
|
||||
|
||||
decoded = new(version, MareCharaFileData.FromByteArray(reader.ReadBytes(dataLength)))
|
||||
{
|
||||
FilePath = path,
|
||||
};
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
public static void AdvanceReaderToData(BinaryReader reader)
|
||||
{
|
||||
reader.ReadChars(4);
|
||||
var version = reader.ReadByte();
|
||||
if (version == 1)
|
||||
{
|
||||
var length = reader.ReadInt32();
|
||||
_ = reader.ReadBytes(length);
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,75 @@
|
||||
using Dalamud.Utility;
|
||||
using Lumina.Excel.Sheets;
|
||||
using MareSynchronos.API.Dto.CharaData;
|
||||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
|
||||
namespace MareSynchronos.Services.CharaData.Models;
|
||||
|
||||
public sealed record PoseEntryExtended : PoseEntry
|
||||
{
|
||||
private PoseEntryExtended(PoseEntry basePose, CharaDataMetaInfoExtendedDto parent) : base(basePose)
|
||||
{
|
||||
HasPoseData = !string.IsNullOrEmpty(basePose.PoseData);
|
||||
HasWorldData = (WorldData ?? default) != default;
|
||||
if (HasWorldData)
|
||||
{
|
||||
Position = new(basePose.WorldData!.Value.PositionX, basePose.WorldData!.Value.PositionY, basePose.WorldData!.Value.PositionZ);
|
||||
Rotation = new(basePose.WorldData!.Value.RotationX, basePose.WorldData!.Value.RotationY, basePose.WorldData!.Value.RotationZ, basePose.WorldData!.Value.RotationW);
|
||||
}
|
||||
MetaInfo = parent;
|
||||
}
|
||||
|
||||
public CharaDataMetaInfoExtendedDto MetaInfo { get; }
|
||||
public bool HasPoseData { get; }
|
||||
public bool HasWorldData { get; }
|
||||
public Vector3 Position { get; } = new();
|
||||
public Vector2 MapCoordinates { get; private set; } = new();
|
||||
public Quaternion Rotation { get; } = new();
|
||||
public Map Map { get; private set; }
|
||||
public string WorldDataDescriptor { get; private set; } = string.Empty;
|
||||
|
||||
public static async Task<PoseEntryExtended> Create(PoseEntry baseEntry, CharaDataMetaInfoExtendedDto parent, DalamudUtilService dalamudUtilService)
|
||||
{
|
||||
PoseEntryExtended newPose = new(baseEntry, parent);
|
||||
|
||||
if (newPose.HasWorldData)
|
||||
{
|
||||
var worldData = newPose.WorldData!.Value;
|
||||
newPose.MapCoordinates = await dalamudUtilService.RunOnFrameworkThread(() =>
|
||||
MapUtil.WorldToMap(new Vector2(worldData.PositionX, worldData.PositionY), dalamudUtilService.MapData.Value[worldData.LocationInfo.MapId].Map))
|
||||
.ConfigureAwait(false);
|
||||
newPose.Map = dalamudUtilService.MapData.Value[worldData.LocationInfo.MapId].Map;
|
||||
|
||||
StringBuilder sb = new();
|
||||
sb.AppendLine("Server: " + dalamudUtilService.WorldData.Value[(ushort)worldData.LocationInfo.ServerId]);
|
||||
sb.AppendLine("Territory: " + dalamudUtilService.TerritoryData.Value[worldData.LocationInfo.TerritoryId]);
|
||||
sb.AppendLine("Map: " + dalamudUtilService.MapData.Value[worldData.LocationInfo.MapId].MapName);
|
||||
|
||||
if (worldData.LocationInfo.WardId != 0)
|
||||
sb.AppendLine("Ward #: " + worldData.LocationInfo.WardId);
|
||||
if (worldData.LocationInfo.DivisionId != 0)
|
||||
{
|
||||
sb.AppendLine("Subdivision: " + worldData.LocationInfo.DivisionId switch
|
||||
{
|
||||
1 => "No",
|
||||
2 => "Yes",
|
||||
_ => "-"
|
||||
});
|
||||
}
|
||||
if (worldData.LocationInfo.HouseId != 0)
|
||||
{
|
||||
sb.AppendLine("House #: " + (worldData.LocationInfo.HouseId == 100 ? "Apartments" : worldData.LocationInfo.HouseId.ToString()));
|
||||
}
|
||||
if (worldData.LocationInfo.RoomId != 0)
|
||||
{
|
||||
sb.AppendLine("Apartment #: " + worldData.LocationInfo.RoomId);
|
||||
}
|
||||
sb.AppendLine("Coordinates: X: " + newPose.MapCoordinates.X.ToString("0.0", CultureInfo.InvariantCulture) + ", Y: " + newPose.MapCoordinates.Y.ToString("0.0", CultureInfo.InvariantCulture));
|
||||
newPose.WorldDataDescriptor = sb.ToString();
|
||||
}
|
||||
|
||||
return newPose;
|
||||
}
|
||||
}
|
242
MareSynchronos/Services/CharacterAnalyzer.cs
Normal file
242
MareSynchronos/Services/CharacterAnalyzer.cs
Normal file
@@ -0,0 +1,242 @@
|
||||
using Lumina.Data.Files;
|
||||
using MareSynchronos.API.Data;
|
||||
using MareSynchronos.API.Data.Enum;
|
||||
using MareSynchronos.FileCache;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using MareSynchronos.UI;
|
||||
using MareSynchronos.Utils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
public sealed class CharacterAnalyzer : DisposableMediatorSubscriberBase
|
||||
{
|
||||
private readonly FileCacheManager _fileCacheManager;
|
||||
private readonly XivDataAnalyzer _xivDataAnalyzer;
|
||||
private CancellationTokenSource? _analysisCts;
|
||||
private CancellationTokenSource _baseAnalysisCts = new();
|
||||
private string _lastDataHash = string.Empty;
|
||||
|
||||
public CharacterAnalyzer(ILogger<CharacterAnalyzer> logger, MareMediator mediator, FileCacheManager fileCacheManager, XivDataAnalyzer modelAnalyzer)
|
||||
: base(logger, mediator)
|
||||
{
|
||||
Mediator.Subscribe<CharacterDataCreatedMessage>(this, (msg) =>
|
||||
{
|
||||
_baseAnalysisCts = _baseAnalysisCts.CancelRecreate();
|
||||
var token = _baseAnalysisCts.Token;
|
||||
_ = BaseAnalysis(msg.CharacterData, token);
|
||||
});
|
||||
_fileCacheManager = fileCacheManager;
|
||||
_xivDataAnalyzer = modelAnalyzer;
|
||||
}
|
||||
|
||||
public int CurrentFile { get; internal set; }
|
||||
public bool IsAnalysisRunning => _analysisCts != null;
|
||||
public int TotalFiles { get; internal set; }
|
||||
internal Dictionary<ObjectKind, Dictionary<string, FileDataEntry>> LastAnalysis { get; } = [];
|
||||
|
||||
public void CancelAnalyze()
|
||||
{
|
||||
_analysisCts?.CancelDispose();
|
||||
_analysisCts = null;
|
||||
}
|
||||
|
||||
public async Task ComputeAnalysis(bool print = true, bool recalculate = false)
|
||||
{
|
||||
Logger.LogDebug("=== Calculating Character Analysis ===");
|
||||
|
||||
_analysisCts = _analysisCts?.CancelRecreate() ?? new();
|
||||
|
||||
var cancelToken = _analysisCts.Token;
|
||||
|
||||
var allFiles = LastAnalysis.SelectMany(v => v.Value.Select(d => d.Value)).ToList();
|
||||
if (allFiles.Exists(c => !c.IsComputed || recalculate))
|
||||
{
|
||||
var remaining = allFiles.Where(c => !c.IsComputed || recalculate).ToList();
|
||||
TotalFiles = remaining.Count;
|
||||
CurrentFile = 1;
|
||||
Logger.LogDebug("=== Computing {amount} remaining files ===", remaining.Count);
|
||||
|
||||
Mediator.Publish(new HaltScanMessage(nameof(CharacterAnalyzer)));
|
||||
try
|
||||
{
|
||||
foreach (var file in remaining)
|
||||
{
|
||||
Logger.LogDebug("Computing file {file}", file.FilePaths[0]);
|
||||
await file.ComputeSizes(_fileCacheManager, cancelToken, ignoreCacheEntries: true).ConfigureAwait(false);
|
||||
CurrentFile++;
|
||||
}
|
||||
|
||||
_fileCacheManager.WriteOutFullCsv();
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to analyze files");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Mediator.Publish(new ResumeScanMessage(nameof(CharacterAnalyzer)));
|
||||
}
|
||||
}
|
||||
|
||||
Mediator.Publish(new CharacterDataAnalyzedMessage());
|
||||
|
||||
_analysisCts.CancelDispose();
|
||||
_analysisCts = null;
|
||||
|
||||
if (print) PrintAnalysis();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (!disposing) return;
|
||||
|
||||
_analysisCts?.CancelDispose();
|
||||
_baseAnalysisCts.CancelDispose();
|
||||
}
|
||||
|
||||
private async Task BaseAnalysis(CharacterData charaData, CancellationToken token)
|
||||
{
|
||||
if (string.Equals(charaData.DataHash.Value, _lastDataHash, StringComparison.Ordinal)) return;
|
||||
|
||||
LastAnalysis.Clear();
|
||||
|
||||
foreach (var obj in charaData.FileReplacements)
|
||||
{
|
||||
Dictionary<string, FileDataEntry> data = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var fileEntry in obj.Value)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var fileCacheEntries = _fileCacheManager.GetAllFileCachesByHash(fileEntry.Hash, ignoreCacheEntries: true, validate: false).ToList();
|
||||
if (fileCacheEntries.Count == 0) continue;
|
||||
|
||||
var filePath = fileCacheEntries[0].ResolvedFilepath;
|
||||
FileInfo fi = new(filePath);
|
||||
string ext = "unk?";
|
||||
try
|
||||
{
|
||||
ext = fi.Extension[1..];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Could not identify extension for {path}", filePath);
|
||||
}
|
||||
|
||||
var tris = await Task.Run(() => _xivDataAnalyzer.GetTrianglesByHash(fileEntry.Hash)).ConfigureAwait(false);
|
||||
|
||||
foreach (var entry in fileCacheEntries)
|
||||
{
|
||||
data[fileEntry.Hash] = new FileDataEntry(fileEntry.Hash, ext,
|
||||
[.. fileEntry.GamePaths],
|
||||
fileCacheEntries.Select(c => c.ResolvedFilepath).Distinct(StringComparer.Ordinal).ToList(),
|
||||
entry.Size > 0 ? entry.Size.Value : 0,
|
||||
entry.CompressedSize > 0 ? entry.CompressedSize.Value : 0,
|
||||
tris);
|
||||
}
|
||||
}
|
||||
|
||||
LastAnalysis[obj.Key] = data;
|
||||
}
|
||||
|
||||
Mediator.Publish(new CharacterDataAnalyzedMessage());
|
||||
|
||||
_lastDataHash = charaData.DataHash.Value;
|
||||
}
|
||||
|
||||
private void PrintAnalysis()
|
||||
{
|
||||
if (LastAnalysis.Count == 0) return;
|
||||
foreach (var kvp in LastAnalysis)
|
||||
{
|
||||
int fileCounter = 1;
|
||||
int totalFiles = kvp.Value.Count;
|
||||
Logger.LogInformation("=== Analysis for {obj} ===", kvp.Key);
|
||||
|
||||
foreach (var entry in kvp.Value.OrderBy(b => b.Value.GamePaths.OrderBy(p => p, StringComparer.Ordinal).First(), StringComparer.Ordinal))
|
||||
{
|
||||
Logger.LogInformation("File {x}/{y}: {hash}", fileCounter++, totalFiles, entry.Key);
|
||||
foreach (var path in entry.Value.GamePaths)
|
||||
{
|
||||
Logger.LogInformation(" Game Path: {path}", path);
|
||||
}
|
||||
if (entry.Value.FilePaths.Count > 1) Logger.LogInformation(" Multiple fitting files detected for {key}", entry.Key);
|
||||
foreach (var filePath in entry.Value.FilePaths)
|
||||
{
|
||||
Logger.LogInformation(" File Path: {path}", filePath);
|
||||
}
|
||||
Logger.LogInformation(" Size: {size}, Compressed: {compressed}", UiSharedService.ByteToString(entry.Value.OriginalSize),
|
||||
UiSharedService.ByteToString(entry.Value.CompressedSize));
|
||||
}
|
||||
}
|
||||
foreach (var kvp in LastAnalysis)
|
||||
{
|
||||
Logger.LogInformation("=== Detailed summary by file type for {obj} ===", kvp.Key);
|
||||
foreach (var entry in kvp.Value.Select(v => v.Value).GroupBy(v => v.FileType, StringComparer.Ordinal))
|
||||
{
|
||||
Logger.LogInformation("{ext} files: {count}, size extracted: {size}, size compressed: {sizeComp}", entry.Key, entry.Count(),
|
||||
UiSharedService.ByteToString(entry.Sum(v => v.OriginalSize)), UiSharedService.ByteToString(entry.Sum(v => v.CompressedSize)));
|
||||
}
|
||||
Logger.LogInformation("=== Total summary for {obj} ===", kvp.Key);
|
||||
Logger.LogInformation("Total files: {count}, size extracted: {size}, size compressed: {sizeComp}", kvp.Value.Count,
|
||||
UiSharedService.ByteToString(kvp.Value.Sum(v => v.Value.OriginalSize)), UiSharedService.ByteToString(kvp.Value.Sum(v => v.Value.CompressedSize)));
|
||||
}
|
||||
|
||||
Logger.LogInformation("=== Total summary for all currently present objects ===");
|
||||
Logger.LogInformation("Total files: {count}, size extracted: {size}, size compressed: {sizeComp}",
|
||||
LastAnalysis.Values.Sum(v => v.Values.Count),
|
||||
UiSharedService.ByteToString(LastAnalysis.Values.Sum(c => c.Values.Sum(v => v.OriginalSize))),
|
||||
UiSharedService.ByteToString(LastAnalysis.Values.Sum(c => c.Values.Sum(v => v.CompressedSize))));
|
||||
Logger.LogInformation("IMPORTANT NOTES:\n\r- For uploads and downloads only the compressed size is relevant.\n\r- An unusually high total files count beyond 200 and up will also increase your download time to others significantly.");
|
||||
}
|
||||
|
||||
internal sealed record FileDataEntry(string Hash, string FileType, List<string> GamePaths, List<string> FilePaths, long OriginalSize, long CompressedSize, long Triangles)
|
||||
{
|
||||
public bool IsComputed => OriginalSize > 0 && CompressedSize > 0;
|
||||
public async Task ComputeSizes(FileCacheManager fileCacheManager, CancellationToken token, bool ignoreCacheEntries = true)
|
||||
{
|
||||
var compressedsize = await fileCacheManager.GetCompressedFileData(Hash, token).ConfigureAwait(false);
|
||||
var normalSize = new FileInfo(FilePaths[0]).Length;
|
||||
var entries = fileCacheManager.GetAllFileCachesByHash(Hash, ignoreCacheEntries: ignoreCacheEntries, validate: false);
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
entry.Size = normalSize;
|
||||
entry.CompressedSize = compressedsize.Item2.LongLength;
|
||||
}
|
||||
OriginalSize = normalSize;
|
||||
CompressedSize = compressedsize.Item2.LongLength;
|
||||
}
|
||||
public long OriginalSize { get; private set; } = OriginalSize;
|
||||
public long CompressedSize { get; private set; } = CompressedSize;
|
||||
public long Triangles { get; private set; } = Triangles;
|
||||
|
||||
public Lazy<string> Format = new(() =>
|
||||
{
|
||||
switch (FileType)
|
||||
{
|
||||
case "tex":
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(FilePaths[0], FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var reader = new BinaryReader(stream);
|
||||
reader.BaseStream.Position = 4;
|
||||
var format = (TexFile.TextureFormat)reader.ReadInt32();
|
||||
var width = reader.ReadInt16();
|
||||
var height = reader.ReadInt16();
|
||||
return $"{format} ({width}x{height})";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
default:
|
||||
return string.Empty;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
241
MareSynchronos/Services/ChatService.cs
Normal file
241
MareSynchronos/Services/ChatService.cs
Normal file
@@ -0,0 +1,241 @@
|
||||
using Dalamud.Game.Text;
|
||||
using Dalamud.Game.Text.SeStringHandling;
|
||||
using Dalamud.Game.Text.SeStringHandling.Payloads;
|
||||
using Dalamud.Plugin.Services;
|
||||
using MareSynchronos.API.Data;
|
||||
using MareSynchronos.Interop;
|
||||
using MareSynchronos.MareConfiguration;
|
||||
using MareSynchronos.MareConfiguration.Models;
|
||||
using MareSynchronos.PlayerData.Pairs;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using MareSynchronos.Services.ServerConfiguration;
|
||||
using MareSynchronos.Utils;
|
||||
using MareSynchronos.WebAPI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
public class ChatService : DisposableMediatorSubscriberBase
|
||||
{
|
||||
public const int DefaultColor = 710;
|
||||
public const int CommandMaxNumber = 50;
|
||||
|
||||
private readonly ILogger<ChatService> _logger;
|
||||
private readonly IChatGui _chatGui;
|
||||
private readonly DalamudUtilService _dalamudUtil;
|
||||
private readonly MareConfigService _mareConfig;
|
||||
private readonly ApiController _apiController;
|
||||
private readonly PairManager _pairManager;
|
||||
private readonly ServerConfigurationManager _serverConfigurationManager;
|
||||
|
||||
private readonly Lazy<GameChatHooks> _gameChatHooks;
|
||||
|
||||
public ChatService(ILogger<ChatService> logger, DalamudUtilService dalamudUtil, MareMediator mediator, ApiController apiController,
|
||||
PairManager pairManager, ILoggerFactory loggerFactory, IGameInteropProvider gameInteropProvider, IChatGui chatGui,
|
||||
MareConfigService mareConfig, ServerConfigurationManager serverConfigurationManager) : base(logger, mediator)
|
||||
{
|
||||
_logger = logger;
|
||||
_dalamudUtil = dalamudUtil;
|
||||
_chatGui = chatGui;
|
||||
_mareConfig = mareConfig;
|
||||
_apiController = apiController;
|
||||
_pairManager = pairManager;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
|
||||
Mediator.Subscribe<UserChatMsgMessage>(this, HandleUserChat);
|
||||
Mediator.Subscribe<GroupChatMsgMessage>(this, HandleGroupChat);
|
||||
|
||||
_gameChatHooks = new(() => new GameChatHooks(loggerFactory.CreateLogger<GameChatHooks>(), gameInteropProvider, SendChatShell));
|
||||
|
||||
// Initialize chat hooks in advance
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = _gameChatHooks.Value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to initialize chat hooks");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (_gameChatHooks.IsValueCreated)
|
||||
_gameChatHooks.Value!.Dispose();
|
||||
}
|
||||
|
||||
private void HandleUserChat(UserChatMsgMessage message)
|
||||
{
|
||||
var chatMsg = message.ChatMsg;
|
||||
var prefix = new SeStringBuilder();
|
||||
prefix.AddText("[BnnuyChat] ");
|
||||
_chatGui.Print(new XivChatEntry{
|
||||
MessageBytes = [..prefix.Build().Encode(), ..message.ChatMsg.PayloadContent],
|
||||
Name = chatMsg.SenderName,
|
||||
Type = XivChatType.TellIncoming
|
||||
});
|
||||
}
|
||||
|
||||
private ushort ResolveShellColor(int shellColor)
|
||||
{
|
||||
if (shellColor != 0)
|
||||
return (ushort)shellColor;
|
||||
var globalColor = _mareConfig.Current.ChatColor;
|
||||
if (globalColor != 0)
|
||||
return (ushort)globalColor;
|
||||
return (ushort)DefaultColor;
|
||||
}
|
||||
|
||||
private XivChatType ResolveShellLogKind(int shellLogKind)
|
||||
{
|
||||
if (shellLogKind != 0)
|
||||
return (XivChatType)shellLogKind;
|
||||
return (XivChatType)_mareConfig.Current.ChatLogKind;
|
||||
}
|
||||
|
||||
private void HandleGroupChat(GroupChatMsgMessage message)
|
||||
{
|
||||
if (_mareConfig.Current.DisableSyncshellChat)
|
||||
return;
|
||||
|
||||
var chatMsg = message.ChatMsg;
|
||||
var shellConfig = _serverConfigurationManager.GetShellConfigForGid(message.GroupInfo.GID);
|
||||
var shellNumber = shellConfig.ShellNumber;
|
||||
|
||||
if (!shellConfig.Enabled)
|
||||
return;
|
||||
|
||||
ushort color = ResolveShellColor(shellConfig.Color);
|
||||
var extraChatTags = _mareConfig.Current.ExtraChatTags;
|
||||
var logKind = ResolveShellLogKind(shellConfig.LogKind);
|
||||
|
||||
var msg = new SeStringBuilder();
|
||||
if (extraChatTags)
|
||||
{
|
||||
msg.Add(ChatUtils.CreateExtraChatTagPayload(message.GroupInfo.GID));
|
||||
msg.Add(RawPayload.LinkTerminator);
|
||||
}
|
||||
if (color != 0)
|
||||
msg.AddUiForeground((ushort)color);
|
||||
msg.AddText($"[SS{shellNumber}]<");
|
||||
if (message.ChatMsg.Sender.UID.Equals(_apiController.UID, StringComparison.Ordinal))
|
||||
{
|
||||
// Don't link to your own character
|
||||
msg.AddText(chatMsg.SenderName);
|
||||
}
|
||||
else
|
||||
{
|
||||
msg.Add(new PlayerPayload(chatMsg.SenderName, chatMsg.SenderHomeWorldId));
|
||||
}
|
||||
msg.AddText("> ");
|
||||
msg.Append(SeString.Parse(message.ChatMsg.PayloadContent));
|
||||
if (color != 0)
|
||||
msg.AddUiForegroundOff();
|
||||
|
||||
_chatGui.Print(new XivChatEntry{
|
||||
Message = msg.Build(),
|
||||
Name = chatMsg.SenderName,
|
||||
Type = logKind
|
||||
});
|
||||
}
|
||||
|
||||
// Print an example message to the configured global chat channel
|
||||
public void PrintChannelExample(string message, string gid = "")
|
||||
{
|
||||
int chatType = _mareConfig.Current.ChatLogKind;
|
||||
|
||||
foreach (var group in _pairManager.Groups)
|
||||
{
|
||||
if (group.Key.GID.Equals(gid, StringComparison.Ordinal))
|
||||
{
|
||||
int shellChatType = _serverConfigurationManager.GetShellConfigForGid(gid).LogKind;
|
||||
if (shellChatType != 0)
|
||||
chatType = shellChatType;
|
||||
}
|
||||
}
|
||||
|
||||
_chatGui.Print(new XivChatEntry{
|
||||
Message = message,
|
||||
Name = "",
|
||||
Type = (XivChatType)chatType
|
||||
});
|
||||
}
|
||||
|
||||
// Called to update the active chat shell name if its renamed
|
||||
public void MaybeUpdateShellName(int shellNumber)
|
||||
{
|
||||
if (_mareConfig.Current.DisableSyncshellChat)
|
||||
return;
|
||||
|
||||
foreach (var group in _pairManager.Groups)
|
||||
{
|
||||
var shellConfig = _serverConfigurationManager.GetShellConfigForGid(group.Key.GID);
|
||||
if (shellConfig.Enabled && shellConfig.ShellNumber == shellNumber)
|
||||
{
|
||||
if (_gameChatHooks.IsValueCreated && _gameChatHooks.Value.ChatChannelOverride != null)
|
||||
{
|
||||
// Very dumb and won't handle re-numbering -- need to identify the active chat channel more reliably later
|
||||
if (_gameChatHooks.Value.ChatChannelOverride.ChannelName.StartsWith($"SS [{shellNumber}]", StringComparison.Ordinal))
|
||||
SwitchChatShell(shellNumber);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SwitchChatShell(int shellNumber)
|
||||
{
|
||||
if (_mareConfig.Current.DisableSyncshellChat)
|
||||
return;
|
||||
|
||||
foreach (var group in _pairManager.Groups)
|
||||
{
|
||||
var shellConfig = _serverConfigurationManager.GetShellConfigForGid(group.Key.GID);
|
||||
if (shellConfig.Enabled && shellConfig.ShellNumber == shellNumber)
|
||||
{
|
||||
var name = _serverConfigurationManager.GetNoteForGid(group.Key.GID) ?? group.Key.AliasOrGID;
|
||||
// BUG: This doesn't always update the chat window e.g. when renaming a group
|
||||
_gameChatHooks.Value.ChatChannelOverride = new()
|
||||
{
|
||||
ChannelName = $"SS [{shellNumber}]: {name}",
|
||||
ChatMessageHandler = chatBytes => SendChatShell(shellNumber, chatBytes)
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_chatGui.PrintError($"[ElezenSync] Syncshell number #{shellNumber} not found");
|
||||
}
|
||||
|
||||
public void SendChatShell(int shellNumber, byte[] chatBytes)
|
||||
{
|
||||
if (_mareConfig.Current.DisableSyncshellChat)
|
||||
return;
|
||||
|
||||
foreach (var group in _pairManager.Groups)
|
||||
{
|
||||
var shellConfig = _serverConfigurationManager.GetShellConfigForGid(group.Key.GID);
|
||||
if (shellConfig.Enabled && shellConfig.ShellNumber == shellNumber)
|
||||
{
|
||||
_ = Task.Run(async () => {
|
||||
// Should cache the name and home world instead of fetching it every time
|
||||
var chatMsg = await _dalamudUtil.RunOnFrameworkThread(() => {
|
||||
return new ChatMessage()
|
||||
{
|
||||
SenderName = _dalamudUtil.GetPlayerName(),
|
||||
SenderHomeWorldId = _dalamudUtil.GetHomeWorldId(),
|
||||
PayloadContent = chatBytes
|
||||
};
|
||||
}).ConfigureAwait(false);
|
||||
await _apiController.GroupChatSendMsg(new(group.Key), chatMsg).ConfigureAwait(false);
|
||||
}).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_chatGui.PrintError($"[ElezenSync] Syncshell number #{shellNumber} not found");
|
||||
}
|
||||
}
|
155
MareSynchronos/Services/CommandManagerService.cs
Normal file
155
MareSynchronos/Services/CommandManagerService.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
using Dalamud.Game.Command;
|
||||
using Dalamud.Plugin.Services;
|
||||
using MareSynchronos.FileCache;
|
||||
using MareSynchronos.MareConfiguration;
|
||||
using MareSynchronos.MareConfiguration.Models;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using MareSynchronos.Services.ServerConfiguration;
|
||||
using MareSynchronos.UI;
|
||||
using MareSynchronos.WebAPI;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
public sealed class CommandManagerService : IDisposable
|
||||
{
|
||||
private const string _commandName = "/sync";
|
||||
private const string _commandName2 = "/elezen";
|
||||
|
||||
private const string _ssCommandPrefix = "/ss";
|
||||
|
||||
private readonly ApiController _apiController;
|
||||
private readonly ICommandManager _commandManager;
|
||||
private readonly MareMediator _mediator;
|
||||
private readonly MareConfigService _mareConfigService;
|
||||
private readonly PerformanceCollectorService _performanceCollectorService;
|
||||
private readonly CacheMonitor _cacheMonitor;
|
||||
private readonly ChatService _chatService;
|
||||
private readonly ServerConfigurationManager _serverConfigurationManager;
|
||||
|
||||
public CommandManagerService(ICommandManager commandManager, PerformanceCollectorService performanceCollectorService,
|
||||
ServerConfigurationManager serverConfigurationManager, CacheMonitor periodicFileScanner, ChatService chatService,
|
||||
ApiController apiController, MareMediator mediator, MareConfigService mareConfigService)
|
||||
{
|
||||
_commandManager = commandManager;
|
||||
_performanceCollectorService = performanceCollectorService;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_cacheMonitor = periodicFileScanner;
|
||||
_chatService = chatService;
|
||||
_apiController = apiController;
|
||||
_mediator = mediator;
|
||||
_mareConfigService = mareConfigService;
|
||||
_commandManager.AddHandler(_commandName, new CommandInfo(OnCommand)
|
||||
{
|
||||
HelpMessage = "Opens the Elezen UI"
|
||||
});
|
||||
_commandManager.AddHandler(_commandName2, new CommandInfo(OnCommand)
|
||||
{
|
||||
HelpMessage = "Opens the Elezen UI"
|
||||
});
|
||||
|
||||
// Lazy registration of all possible /ss# commands which tbf is what the game does for linkshells anyway
|
||||
for (int i = 1; i <= ChatService.CommandMaxNumber; ++i)
|
||||
{
|
||||
_commandManager.AddHandler($"{_ssCommandPrefix}{i}", new CommandInfo(OnChatCommand)
|
||||
{
|
||||
ShowInHelp = false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_commandManager.RemoveHandler(_commandName);
|
||||
_commandManager.RemoveHandler(_commandName2);
|
||||
|
||||
for (int i = 1; i <= ChatService.CommandMaxNumber; ++i)
|
||||
_commandManager.RemoveHandler($"{_ssCommandPrefix}{i}");
|
||||
}
|
||||
|
||||
private void OnCommand(string command, string args)
|
||||
{
|
||||
var splitArgs = args.ToLowerInvariant().Trim().Split(" ", StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (splitArgs.Length == 0)
|
||||
{
|
||||
// Interpret this as toggling the UI
|
||||
if (_mareConfigService.Current.HasValidSetup())
|
||||
_mediator.Publish(new UiToggleMessage(typeof(CompactUi)));
|
||||
else
|
||||
_mediator.Publish(new UiToggleMessage(typeof(IntroUi)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(splitArgs[0], "toggle", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (_apiController.ServerState == WebAPI.SignalR.Utils.ServerState.Disconnecting)
|
||||
{
|
||||
_mediator.Publish(new NotificationMessage("Elezen disconnecting", "Cannot use /toggle while Elezen is still disconnecting",
|
||||
NotificationType.Error));
|
||||
}
|
||||
|
||||
if (_serverConfigurationManager.CurrentServer == null) return;
|
||||
var fullPause = splitArgs.Length > 1 ? splitArgs[1] switch
|
||||
{
|
||||
"on" => false,
|
||||
"off" => true,
|
||||
_ => !_serverConfigurationManager.CurrentServer.FullPause,
|
||||
} : !_serverConfigurationManager.CurrentServer.FullPause;
|
||||
|
||||
if (fullPause != _serverConfigurationManager.CurrentServer.FullPause)
|
||||
{
|
||||
_serverConfigurationManager.CurrentServer.FullPause = fullPause;
|
||||
_serverConfigurationManager.Save();
|
||||
_ = _apiController.CreateConnections();
|
||||
}
|
||||
}
|
||||
else if (string.Equals(splitArgs[0], "gpose", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_mediator.Publish(new UiToggleMessage(typeof(CharaDataHubUi)));
|
||||
}
|
||||
else if (string.Equals(splitArgs[0], "rescan", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_cacheMonitor.InvokeScan();
|
||||
}
|
||||
else if (string.Equals(splitArgs[0], "perf", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (splitArgs.Length > 1 && int.TryParse(splitArgs[1], CultureInfo.InvariantCulture, out var limitBySeconds))
|
||||
{
|
||||
_performanceCollectorService.PrintPerformanceStats(limitBySeconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
_performanceCollectorService.PrintPerformanceStats();
|
||||
}
|
||||
}
|
||||
else if (string.Equals(splitArgs[0], "medi", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_mediator.PrintSubscriberInfo();
|
||||
}
|
||||
else if (string.Equals(splitArgs[0], "analyze", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_mediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi)));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnChatCommand(string command, string args)
|
||||
{
|
||||
if (_mareConfigService.Current.DisableSyncshellChat)
|
||||
return;
|
||||
|
||||
int shellNumber = int.Parse(command[_ssCommandPrefix.Length..]);
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
_chatService.SwitchChatShell(shellNumber);
|
||||
}
|
||||
else
|
||||
{
|
||||
// FIXME: Chat content seems to already be stripped of any special characters here?
|
||||
byte[] chatBytes = Encoding.UTF8.GetBytes(args);
|
||||
_chatService.SendChatShell(shellNumber, chatBytes);
|
||||
}
|
||||
}
|
||||
}
|
794
MareSynchronos/Services/DalamudUtilService.cs
Normal file
794
MareSynchronos/Services/DalamudUtilService.cs
Normal file
@@ -0,0 +1,794 @@
|
||||
using Dalamud.Game.ClientState.Conditions;
|
||||
using Dalamud.Game.ClientState.Objects;
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using Dalamud.Game.ClientState.Objects.Types;
|
||||
using Dalamud.Plugin.Services;
|
||||
using Dalamud.Utility;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Control;
|
||||
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
|
||||
using Lumina.Excel.Sheets;
|
||||
using MareSynchronos.API.Dto.CharaData;
|
||||
using MareSynchronos.Interop;
|
||||
using MareSynchronos.PlayerData.Handlers;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using MareSynchronos.Utils;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Numerics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject;
|
||||
using DalamudGameObject = Dalamud.Game.ClientState.Objects.Types.IGameObject;
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
{
|
||||
public struct PlayerCharacter
|
||||
{
|
||||
public uint ObjectId;
|
||||
public string Name;
|
||||
public uint HomeWorldId;
|
||||
public nint Address;
|
||||
};
|
||||
|
||||
private struct PlayerInfo
|
||||
{
|
||||
public PlayerCharacter Character;
|
||||
public string Hash;
|
||||
};
|
||||
|
||||
private readonly List<uint> _classJobIdsIgnoredForPets = [30];
|
||||
private readonly IClientState _clientState;
|
||||
private readonly ICondition _condition;
|
||||
private readonly IDataManager _gameData;
|
||||
private readonly BlockedCharacterHandler _blockedCharacterHandler;
|
||||
private readonly IFramework _framework;
|
||||
private readonly IGameGui _gameGui;
|
||||
private readonly IToastGui _toastGui;
|
||||
private readonly ILogger<DalamudUtilService> _logger;
|
||||
private readonly IObjectTable _objectTable;
|
||||
private readonly PerformanceCollectorService _performanceCollector;
|
||||
private uint? _classJobId = 0;
|
||||
private DateTime _delayedFrameworkUpdateCheck = DateTime.UtcNow;
|
||||
private string _lastGlobalBlockPlayer = string.Empty;
|
||||
private string _lastGlobalBlockReason = string.Empty;
|
||||
private ushort _lastZone = 0;
|
||||
private readonly Dictionary<string, PlayerCharacter> _playerCharas = new(StringComparer.Ordinal);
|
||||
private readonly List<string> _notUpdatedCharas = [];
|
||||
private bool _sentBetweenAreas = false;
|
||||
private static readonly Dictionary<uint, PlayerInfo> _playerInfoCache = new();
|
||||
|
||||
|
||||
public DalamudUtilService(ILogger<DalamudUtilService> logger, IClientState clientState, IObjectTable objectTable, IFramework framework,
|
||||
IGameGui gameGui, IToastGui toastGui,ICondition condition, IDataManager gameData, ITargetManager targetManager,
|
||||
BlockedCharacterHandler blockedCharacterHandler, MareMediator mediator, PerformanceCollectorService performanceCollector)
|
||||
{
|
||||
_logger = logger;
|
||||
_clientState = clientState;
|
||||
_objectTable = objectTable;
|
||||
_framework = framework;
|
||||
_gameGui = gameGui;
|
||||
_toastGui = toastGui;
|
||||
_condition = condition;
|
||||
_gameData = gameData;
|
||||
_blockedCharacterHandler = blockedCharacterHandler;
|
||||
Mediator = mediator;
|
||||
_performanceCollector = performanceCollector;
|
||||
WorldData = new(() =>
|
||||
{
|
||||
return gameData.GetExcelSheet<Lumina.Excel.Sheets.World>(Dalamud.Game.ClientLanguage.English)!
|
||||
.Where(w => w.Name.ByteLength > 0 && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper((char)w.Name.Data.Span[0])))
|
||||
.ToDictionary(w => (ushort)w.RowId, w => w.Name.ToString());
|
||||
});
|
||||
UiColors = new(() =>
|
||||
{
|
||||
return gameData.GetExcelSheet<Lumina.Excel.Sheets.UIColor>(Dalamud.Game.ClientLanguage.English)!
|
||||
.Where(x => x.RowId != 0 && !(x.RowId >= 500 && (x.Dark & 0xFFFFFF00) == 0))
|
||||
.ToDictionary(x => (int)x.RowId);
|
||||
});
|
||||
TerritoryData = new(() =>
|
||||
{
|
||||
return gameData.GetExcelSheet<Lumina.Excel.Sheets.TerritoryType>(Dalamud.Game.ClientLanguage.English)!
|
||||
.Where(w => w.RowId != 0)
|
||||
.ToDictionary(w => w.RowId, w =>
|
||||
{
|
||||
StringBuilder sb = new();
|
||||
sb.Append(w.PlaceNameRegion.Value.Name);
|
||||
if (w.PlaceName.ValueNullable != null)
|
||||
{
|
||||
sb.Append(" - ");
|
||||
sb.Append(w.PlaceName.Value.Name);
|
||||
}
|
||||
return sb.ToString();
|
||||
});
|
||||
});
|
||||
MapData = new(() =>
|
||||
{
|
||||
return gameData.GetExcelSheet<Lumina.Excel.Sheets.Map>(Dalamud.Game.ClientLanguage.English)!
|
||||
.Where(w => w.RowId != 0)
|
||||
.ToDictionary(w => w.RowId, w =>
|
||||
{
|
||||
StringBuilder sb = new();
|
||||
sb.Append(w.PlaceNameRegion.Value.Name);
|
||||
if (w.PlaceName.ValueNullable != null)
|
||||
{
|
||||
sb.Append(" - ");
|
||||
sb.Append(w.PlaceName.Value.Name);
|
||||
}
|
||||
if (w.PlaceNameSub.ValueNullable != null && !string.IsNullOrEmpty(w.PlaceNameSub.Value.Name.ToString()))
|
||||
{
|
||||
sb.Append(" - ");
|
||||
sb.Append(w.PlaceNameSub.Value.Name);
|
||||
}
|
||||
return (w, sb.ToString());
|
||||
});
|
||||
});
|
||||
mediator.Subscribe<TargetPairMessage>(this, (msg) =>
|
||||
{
|
||||
if (clientState.IsPvP) return;
|
||||
var ident = msg.Pair.GetPlayerNameHash();
|
||||
_ = RunOnFrameworkThread(() =>
|
||||
{
|
||||
var addr = GetPlayerCharacterFromCachedTableByIdent(ident);
|
||||
var pc = _clientState.LocalPlayer!;
|
||||
var gobj = CreateGameObject(addr);
|
||||
// Any further than roughly 55y is out of range for targetting
|
||||
if (gobj != null && Vector3.Distance(pc.Position, gobj.Position) < 55.0f)
|
||||
targetManager.Target = gobj;
|
||||
else
|
||||
_toastGui.ShowError("Player out of range.");
|
||||
}).ConfigureAwait(false);
|
||||
});
|
||||
IsWine = Util.IsWine();
|
||||
}
|
||||
|
||||
public bool IsWine { get; init; }
|
||||
public unsafe GameObject* GposeTarget
|
||||
{
|
||||
get => TargetSystem.Instance()->GPoseTarget;
|
||||
set => TargetSystem.Instance()->GPoseTarget = value;
|
||||
}
|
||||
|
||||
private unsafe bool HasGposeTarget => GposeTarget != null;
|
||||
private unsafe int GPoseTargetIdx => !HasGposeTarget ? -1 : GposeTarget->ObjectIndex;
|
||||
|
||||
public async Task<IGameObject?> GetGposeTargetGameObjectAsync()
|
||||
{
|
||||
if (!HasGposeTarget)
|
||||
return null;
|
||||
|
||||
return await _framework.RunOnFrameworkThread(() => _objectTable[GPoseTargetIdx]).ConfigureAwait(true);
|
||||
}
|
||||
public bool IsAnythingDrawing { get; private set; } = false;
|
||||
public bool IsInCutscene { get; private set; } = false;
|
||||
public bool IsInGpose { get; private set; } = false;
|
||||
public bool IsLoggedIn { get; private set; }
|
||||
public bool IsOnFrameworkThread => _framework.IsInFrameworkUpdateThread;
|
||||
public bool IsZoning => _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51];
|
||||
public bool IsInCombatOrPerforming { get; private set; } = false;
|
||||
public bool HasModifiedGameFiles => _gameData.HasModifiedGameDataFiles;
|
||||
|
||||
public Lazy<Dictionary<ushort, string>> WorldData { get; private set; }
|
||||
public Lazy<Dictionary<int, Lumina.Excel.Sheets.UIColor>> UiColors { get; private set; }
|
||||
public Lazy<Dictionary<uint, string>> TerritoryData { get; private set; }
|
||||
public Lazy<Dictionary<uint, (Lumina.Excel.Sheets.Map Map, string MapName)>> MapData { get; private set; }
|
||||
|
||||
public MareMediator Mediator { get; }
|
||||
|
||||
public Dalamud.Game.ClientState.Objects.Types.IGameObject? CreateGameObject(IntPtr reference)
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
return _objectTable.CreateObjectReference(reference);
|
||||
}
|
||||
|
||||
public async Task<Dalamud.Game.ClientState.Objects.Types.IGameObject?> CreateGameObjectAsync(IntPtr reference)
|
||||
{
|
||||
return await RunOnFrameworkThread(() => _objectTable.CreateObjectReference(reference)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void EnsureIsOnFramework()
|
||||
{
|
||||
if (!_framework.IsInFrameworkUpdateThread) throw new InvalidOperationException("Can only be run on Framework");
|
||||
}
|
||||
|
||||
public Dalamud.Game.ClientState.Objects.Types.ICharacter? GetCharacterFromObjectTableByIndex(int index)
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
var objTableObj = _objectTable[index];
|
||||
if (objTableObj!.ObjectKind != Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player) return null;
|
||||
return (Dalamud.Game.ClientState.Objects.Types.ICharacter)objTableObj;
|
||||
}
|
||||
|
||||
public unsafe IntPtr GetCompanion(IntPtr? playerPointer = null)
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
var mgr = CharacterManager.Instance();
|
||||
playerPointer ??= GetPlayerPointer();
|
||||
if (playerPointer == IntPtr.Zero || (IntPtr)mgr == IntPtr.Zero) return IntPtr.Zero;
|
||||
return (IntPtr)mgr->LookupBuddyByOwnerObject((BattleChara*)playerPointer);
|
||||
}
|
||||
|
||||
public async Task<IntPtr> GetCompanionAsync(IntPtr? playerPointer = null)
|
||||
{
|
||||
return await RunOnFrameworkThread(() => GetCompanion(playerPointer)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ICharacter?> GetGposeCharacterFromObjectTableByNameAsync(string name, bool onlyGposeCharacters = false)
|
||||
{
|
||||
return await RunOnFrameworkThread(() => GetGposeCharacterFromObjectTableByName(name, onlyGposeCharacters)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public ICharacter? GetGposeCharacterFromObjectTableByName(string name, bool onlyGposeCharacters = false)
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
return (ICharacter?)_objectTable
|
||||
.FirstOrDefault(i => (!onlyGposeCharacters || i.ObjectIndex >= 200) && string.Equals(i.Name.ToString(), name, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public IEnumerable<ICharacter?> GetGposeCharactersFromObjectTable()
|
||||
{
|
||||
return _objectTable.Where(o => o.ObjectIndex > 200 && o.ObjectKind == Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player).Cast<ICharacter>();
|
||||
}
|
||||
|
||||
public bool GetIsPlayerPresent()
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
return _clientState.LocalPlayer != null && _clientState.LocalPlayer.IsValid();
|
||||
}
|
||||
|
||||
public async Task<bool> GetIsPlayerPresentAsync()
|
||||
{
|
||||
return await RunOnFrameworkThread(GetIsPlayerPresent).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public unsafe IntPtr GetMinionOrMount(IntPtr? playerPointer = null)
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
playerPointer ??= GetPlayerPointer();
|
||||
if (playerPointer == IntPtr.Zero) return IntPtr.Zero;
|
||||
return _objectTable.GetObjectAddress(((GameObject*)playerPointer)->ObjectIndex + 1);
|
||||
}
|
||||
|
||||
public async Task<IntPtr> GetMinionOrMountAsync(IntPtr? playerPointer = null)
|
||||
{
|
||||
return await RunOnFrameworkThread(() => GetMinionOrMount(playerPointer)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public unsafe IntPtr GetPet(IntPtr? playerPointer = null)
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
if (_classJobIdsIgnoredForPets.Contains(_classJobId ?? 0)) return IntPtr.Zero;
|
||||
var mgr = CharacterManager.Instance();
|
||||
playerPointer ??= GetPlayerPointer();
|
||||
if (playerPointer == IntPtr.Zero || (IntPtr)mgr == IntPtr.Zero) return IntPtr.Zero;
|
||||
return (IntPtr)mgr->LookupPetByOwnerObject((BattleChara*)playerPointer);
|
||||
}
|
||||
|
||||
public async Task<IntPtr> GetPetAsync(IntPtr? playerPointer = null)
|
||||
{
|
||||
return await RunOnFrameworkThread(() => GetPet(playerPointer)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IPlayerCharacter> GetPlayerCharacterAsync()
|
||||
{
|
||||
return await RunOnFrameworkThread(GetPlayerCharacter).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public IPlayerCharacter GetPlayerCharacter()
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
return _clientState.LocalPlayer!;
|
||||
}
|
||||
|
||||
public IntPtr GetPlayerCharacterFromCachedTableByName(string characterName)
|
||||
{
|
||||
foreach (var c in _playerCharas.Values)
|
||||
{
|
||||
if (c.Name.Equals(characterName, StringComparison.Ordinal))
|
||||
return c.Address;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public IntPtr GetPlayerCharacterFromCachedTableByIdent(string characterName)
|
||||
{
|
||||
if (_playerCharas.TryGetValue(characterName, out var pchar)) return pchar.Address;
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
public string GetPlayerName()
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
return _clientState.LocalPlayer?.Name.ToString() ?? "--";
|
||||
}
|
||||
|
||||
public async Task<string> GetPlayerNameAsync()
|
||||
{
|
||||
return await RunOnFrameworkThread(GetPlayerName).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<string> GetPlayerNameHashedAsync()
|
||||
{
|
||||
return await RunOnFrameworkThread(() => (GetPlayerName() + GetHomeWorldId()).GetHash256()).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public IntPtr GetPlayerPointer()
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
return _clientState.LocalPlayer?.Address ?? IntPtr.Zero;
|
||||
}
|
||||
|
||||
public async Task<IntPtr> GetPlayerPointerAsync()
|
||||
{
|
||||
return await RunOnFrameworkThread(GetPlayerPointer).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public uint GetHomeWorldId()
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
return _clientState.LocalPlayer!.HomeWorld.RowId;
|
||||
}
|
||||
|
||||
public uint GetWorldId()
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
return _clientState.LocalPlayer!.CurrentWorld.RowId;
|
||||
}
|
||||
|
||||
public unsafe LocationInfo GetMapData()
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
var agentMap = AgentMap.Instance();
|
||||
var houseMan = HousingManager.Instance();
|
||||
uint serverId = 0;
|
||||
if (_clientState.LocalPlayer == null) serverId = 0;
|
||||
else serverId = _clientState.LocalPlayer.CurrentWorld.RowId;
|
||||
uint mapId = agentMap == null ? 0 : agentMap->CurrentMapId;
|
||||
uint territoryId = agentMap == null ? 0 : agentMap->CurrentTerritoryId;
|
||||
uint divisionId = houseMan == null ? 0 : (uint)(houseMan->GetCurrentDivision());
|
||||
uint wardId = houseMan == null ? 0 : (uint)(houseMan->GetCurrentWard() + 1);
|
||||
uint houseId = 0;
|
||||
var tempHouseId = houseMan == null ? 0 : (houseMan->GetCurrentPlot());
|
||||
if (!houseMan->IsInside()) tempHouseId = 0;
|
||||
if (tempHouseId < -1)
|
||||
{
|
||||
divisionId = tempHouseId == -127 ? 2 : (uint)1;
|
||||
tempHouseId = 100;
|
||||
}
|
||||
if (tempHouseId == -1) tempHouseId = 0;
|
||||
houseId = (uint)tempHouseId;
|
||||
if (houseId != 0)
|
||||
{
|
||||
territoryId = HousingManager.GetOriginalHouseTerritoryTypeId();
|
||||
}
|
||||
uint roomId = houseMan == null ? 0 : (uint)(houseMan->GetCurrentRoom());
|
||||
|
||||
return new LocationInfo()
|
||||
{
|
||||
ServerId = serverId,
|
||||
MapId = mapId,
|
||||
TerritoryId = territoryId,
|
||||
DivisionId = divisionId,
|
||||
WardId = wardId,
|
||||
HouseId = houseId,
|
||||
RoomId = roomId
|
||||
};
|
||||
}
|
||||
|
||||
public unsafe void SetMarkerAndOpenMap(Vector3 position, Map map)
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
var agentMap = AgentMap.Instance();
|
||||
if (agentMap == null) return;
|
||||
agentMap->OpenMapByMapId(map.RowId);
|
||||
agentMap->SetFlagMapMarker(map.TerritoryType.RowId, map.RowId, position);
|
||||
}
|
||||
|
||||
public async Task<LocationInfo> GetMapDataAsync()
|
||||
{
|
||||
return await RunOnFrameworkThread(GetMapData).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<uint> GetWorldIdAsync()
|
||||
{
|
||||
return await RunOnFrameworkThread(GetWorldId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<uint> GetHomeWorldIdAsync()
|
||||
{
|
||||
return await RunOnFrameworkThread(GetHomeWorldId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public unsafe bool IsGameObjectPresent(IntPtr key)
|
||||
{
|
||||
return _objectTable.Any(f => f.Address == key);
|
||||
}
|
||||
|
||||
public bool IsObjectPresent(Dalamud.Game.ClientState.Objects.Types.IGameObject? obj)
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
return obj != null && obj.IsValid();
|
||||
}
|
||||
|
||||
public async Task<bool> IsObjectPresentAsync(Dalamud.Game.ClientState.Objects.Types.IGameObject? obj)
|
||||
{
|
||||
return await RunOnFrameworkThread(() => IsObjectPresent(obj)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task RunOnFrameworkThread(System.Action act, [CallerMemberName] string callerMember = "", [CallerFilePath] string callerFilePath = "", [CallerLineNumber] int callerLineNumber = 0)
|
||||
{
|
||||
var fileName = Path.GetFileNameWithoutExtension(callerFilePath);
|
||||
await _performanceCollector.LogPerformance(this, $"RunOnFramework:Act/{fileName}>{callerMember}:{callerLineNumber}", async () =>
|
||||
{
|
||||
if (!_framework.IsInFrameworkUpdateThread)
|
||||
{
|
||||
await _framework.RunOnFrameworkThread(act).ContinueWith((_) => Task.CompletedTask).ConfigureAwait(false);
|
||||
while (_framework.IsInFrameworkUpdateThread) // yield the thread again, should technically never be triggered
|
||||
{
|
||||
_logger.LogTrace("Still on framework");
|
||||
await Task.Delay(1).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
act();
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<T> RunOnFrameworkThread<T>(Func<T> func, [CallerMemberName] string callerMember = "", [CallerFilePath] string callerFilePath = "", [CallerLineNumber] int callerLineNumber = 0)
|
||||
{
|
||||
var fileName = Path.GetFileNameWithoutExtension(callerFilePath);
|
||||
return await _performanceCollector.LogPerformance(this, $"RunOnFramework:Func<{typeof(T)}>/{fileName}>{callerMember}:{callerLineNumber}", async () =>
|
||||
{
|
||||
if (!_framework.IsInFrameworkUpdateThread)
|
||||
{
|
||||
var result = await _framework.RunOnFrameworkThread(func).ContinueWith((task) => task.Result).ConfigureAwait(false);
|
||||
while (_framework.IsInFrameworkUpdateThread) // yield the thread again, should technically never be triggered
|
||||
{
|
||||
_logger.LogTrace("Still on framework");
|
||||
await Task.Delay(1).ConfigureAwait(false);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return func.Invoke();
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting DalamudUtilService");
|
||||
#pragma warning disable S2696 // Instance members should not write to "static" fields
|
||||
ElezenSync.Plugin.Self.RealOnFrameworkUpdate = this.FrameworkOnUpdate;
|
||||
#pragma warning restore S2696
|
||||
_framework.Update += ElezenSync.Plugin.Self.OnFrameworkUpdate;
|
||||
if (IsLoggedIn)
|
||||
{
|
||||
_classJobId = _clientState.LocalPlayer!.ClassJob.RowId;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Started DalamudUtilService");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogTrace("Stopping {type}", GetType());
|
||||
|
||||
Mediator.UnsubscribeAll(this);
|
||||
_framework.Update -= ElezenSync.Plugin.Self.OnFrameworkUpdate;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task WaitWhileCharacterIsDrawing(ILogger logger, GameObjectHandler handler, Guid redrawId, int timeOut = 5000, CancellationToken? ct = null)
|
||||
{
|
||||
if (!_clientState.IsLoggedIn) return;
|
||||
|
||||
const int tick = 250;
|
||||
int curWaitTime = 0;
|
||||
try
|
||||
{
|
||||
logger.LogTrace("[{redrawId}] Starting wait for {handler} to draw", redrawId, handler);
|
||||
await Task.Delay(tick).ConfigureAwait(true);
|
||||
curWaitTime += tick;
|
||||
|
||||
while ((!ct?.IsCancellationRequested ?? true)
|
||||
&& curWaitTime < timeOut
|
||||
&& await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false)) // 0b100000000000 is "still rendering" or something
|
||||
{
|
||||
logger.LogTrace("[{redrawId}] Waiting for {handler} to finish drawing", redrawId, handler);
|
||||
curWaitTime += tick;
|
||||
await Task.Delay(tick).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
logger.LogTrace("[{redrawId}] Finished drawing after {curWaitTime}ms", redrawId, curWaitTime);
|
||||
}
|
||||
catch (NullReferenceException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Error accessing {handler}, object does not exist anymore?", handler);
|
||||
}
|
||||
catch (AccessViolationException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Error accessing {handler}, object does not exist anymore?", handler);
|
||||
}
|
||||
}
|
||||
|
||||
public unsafe void WaitWhileGposeCharacterIsDrawing(IntPtr characterAddress, int timeOut = 5000)
|
||||
{
|
||||
Thread.Sleep(500);
|
||||
var obj = (GameObject*)characterAddress;
|
||||
const int tick = 250;
|
||||
int curWaitTime = 0;
|
||||
_logger.LogTrace("RenderFlags: {flags}", obj->RenderFlags.ToString("X"));
|
||||
while (obj->RenderFlags != 0x00 && curWaitTime < timeOut)
|
||||
{
|
||||
_logger.LogTrace($"Waiting for gpose actor to finish drawing");
|
||||
curWaitTime += tick;
|
||||
Thread.Sleep(tick);
|
||||
}
|
||||
|
||||
Thread.Sleep(tick * 2);
|
||||
}
|
||||
|
||||
public Vector2 WorldToScreen(Dalamud.Game.ClientState.Objects.Types.IGameObject? obj)
|
||||
{
|
||||
if (obj == null) return Vector2.Zero;
|
||||
return _gameGui.WorldToScreen(obj.Position, out var screenPos) ? screenPos : Vector2.Zero;
|
||||
}
|
||||
|
||||
public PlayerCharacter FindPlayerByNameHash(string ident)
|
||||
{
|
||||
_playerCharas.TryGetValue(ident, out var result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private unsafe PlayerInfo GetPlayerInfo(DalamudGameObject chara)
|
||||
{
|
||||
uint id = chara.EntityId;
|
||||
|
||||
if (!_playerInfoCache.TryGetValue(id, out var info))
|
||||
{
|
||||
info.Character.ObjectId = id;
|
||||
info.Character.Name = chara.Name.TextValue; // ?
|
||||
info.Character.HomeWorldId = ((BattleChara*)chara.Address)->Character.HomeWorld;
|
||||
info.Character.Address = chara.Address;
|
||||
info.Hash = Crypto.GetHash256(info.Character.Name + info.Character.HomeWorldId.ToString());
|
||||
_playerInfoCache[id] = info;
|
||||
}
|
||||
|
||||
info.Character.Address = chara.Address;
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
private unsafe void CheckCharacterForDrawing(PlayerCharacter p)
|
||||
{
|
||||
var gameObj = (GameObject*)p.Address;
|
||||
var drawObj = gameObj->DrawObject;
|
||||
var characterName = p.Name;
|
||||
bool isDrawing = false;
|
||||
bool isDrawingChanged = false;
|
||||
if ((nint)drawObj != IntPtr.Zero)
|
||||
{
|
||||
isDrawing = gameObj->RenderFlags == 0b100000000000;
|
||||
if (!isDrawing)
|
||||
{
|
||||
isDrawing = ((CharacterBase*)drawObj)->HasModelInSlotLoaded != 0;
|
||||
if (!isDrawing)
|
||||
{
|
||||
isDrawing = ((CharacterBase*)drawObj)->HasModelFilesInSlotLoaded != 0;
|
||||
if (isDrawing && !string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal)
|
||||
&& !string.Equals(_lastGlobalBlockReason, "HasModelFilesInSlotLoaded", StringComparison.Ordinal))
|
||||
{
|
||||
_lastGlobalBlockPlayer = characterName;
|
||||
_lastGlobalBlockReason = "HasModelFilesInSlotLoaded";
|
||||
isDrawingChanged = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal)
|
||||
&& !string.Equals(_lastGlobalBlockReason, "HasModelInSlotLoaded", StringComparison.Ordinal))
|
||||
{
|
||||
_lastGlobalBlockPlayer = characterName;
|
||||
_lastGlobalBlockReason = "HasModelInSlotLoaded";
|
||||
isDrawingChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal)
|
||||
&& !string.Equals(_lastGlobalBlockReason, "RenderFlags", StringComparison.Ordinal))
|
||||
{
|
||||
_lastGlobalBlockPlayer = characterName;
|
||||
_lastGlobalBlockReason = "RenderFlags";
|
||||
isDrawingChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isDrawingChanged)
|
||||
{
|
||||
_logger.LogTrace("Global draw block: START => {name} ({reason})", characterName, _lastGlobalBlockReason);
|
||||
}
|
||||
|
||||
IsAnythingDrawing |= isDrawing;
|
||||
}
|
||||
|
||||
private void FrameworkOnUpdate(IFramework framework)
|
||||
{
|
||||
_performanceCollector.LogPerformance(this, $"FrameworkOnUpdate", FrameworkOnUpdateInternal);
|
||||
}
|
||||
|
||||
private unsafe void FrameworkOnUpdateInternal()
|
||||
{
|
||||
if (_clientState.LocalPlayer?.IsDead ?? false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool isNormalFrameworkUpdate = DateTime.UtcNow < _delayedFrameworkUpdateCheck.AddMilliseconds(200);
|
||||
|
||||
_performanceCollector.LogPerformance(this, $"FrameworkOnUpdateInternal+{(isNormalFrameworkUpdate ? "Regular" : "Delayed")}", () =>
|
||||
{
|
||||
IsAnythingDrawing = false;
|
||||
_performanceCollector.LogPerformance(this, $"ObjTableToCharas",
|
||||
() =>
|
||||
{
|
||||
if (_sentBetweenAreas)
|
||||
return;
|
||||
|
||||
_notUpdatedCharas.AddRange(_playerCharas.Keys);
|
||||
|
||||
for (int i = 0; i < 200; i += 2)
|
||||
{
|
||||
var chara = _objectTable[i];
|
||||
if (chara == null || chara.ObjectKind != Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player)
|
||||
continue;
|
||||
|
||||
if (_blockedCharacterHandler.IsCharacterBlocked(chara.Address, out bool firstTime) && firstTime)
|
||||
{
|
||||
_logger.LogTrace("Skipping character {addr}, blocked/muted", chara.Address.ToString("X"));
|
||||
continue;
|
||||
}
|
||||
|
||||
var info = GetPlayerInfo(chara);
|
||||
|
||||
if (!IsAnythingDrawing)
|
||||
CheckCharacterForDrawing(info.Character);
|
||||
_notUpdatedCharas.Remove(info.Hash);
|
||||
_playerCharas[info.Hash] = info.Character;
|
||||
}
|
||||
|
||||
foreach (var notUpdatedChara in _notUpdatedCharas)
|
||||
{
|
||||
_playerCharas.Remove(notUpdatedChara);
|
||||
}
|
||||
|
||||
_notUpdatedCharas.Clear();
|
||||
});
|
||||
|
||||
if (!IsAnythingDrawing && !string.IsNullOrEmpty(_lastGlobalBlockPlayer))
|
||||
{
|
||||
_logger.LogTrace("Global draw block: END => {name}", _lastGlobalBlockPlayer);
|
||||
_lastGlobalBlockPlayer = string.Empty;
|
||||
_lastGlobalBlockReason = string.Empty;
|
||||
}
|
||||
|
||||
if (_clientState.IsGPosing && !IsInGpose)
|
||||
{
|
||||
_logger.LogDebug("Gpose start");
|
||||
IsInGpose = true;
|
||||
Mediator.Publish(new GposeStartMessage());
|
||||
}
|
||||
else if (!_clientState.IsGPosing && IsInGpose)
|
||||
{
|
||||
_logger.LogDebug("Gpose end");
|
||||
IsInGpose = false;
|
||||
Mediator.Publish(new GposeEndMessage());
|
||||
}
|
||||
|
||||
if ((_condition[ConditionFlag.Performing] || _condition[ConditionFlag.InCombat]) && !IsInCombatOrPerforming)
|
||||
{
|
||||
_logger.LogDebug("Combat/Performance start");
|
||||
IsInCombatOrPerforming = true;
|
||||
Mediator.Publish(new CombatOrPerformanceStartMessage());
|
||||
Mediator.Publish(new HaltScanMessage(nameof(IsInCombatOrPerforming)));
|
||||
}
|
||||
else if ((!_condition[ConditionFlag.Performing] && !_condition[ConditionFlag.InCombat]) && IsInCombatOrPerforming)
|
||||
{
|
||||
_logger.LogDebug("Combat/Performance end");
|
||||
IsInCombatOrPerforming = false;
|
||||
Mediator.Publish(new CombatOrPerformanceEndMessage());
|
||||
Mediator.Publish(new ResumeScanMessage(nameof(IsInCombatOrPerforming)));
|
||||
}
|
||||
|
||||
if (_condition[ConditionFlag.WatchingCutscene] && !IsInCutscene)
|
||||
{
|
||||
_logger.LogDebug("Cutscene start");
|
||||
IsInCutscene = true;
|
||||
Mediator.Publish(new CutsceneStartMessage());
|
||||
Mediator.Publish(new HaltScanMessage(nameof(IsInCutscene)));
|
||||
}
|
||||
else if (!_condition[ConditionFlag.WatchingCutscene] && IsInCutscene)
|
||||
{
|
||||
_logger.LogDebug("Cutscene end");
|
||||
IsInCutscene = false;
|
||||
Mediator.Publish(new CutsceneEndMessage());
|
||||
Mediator.Publish(new ResumeScanMessage(nameof(IsInCutscene)));
|
||||
}
|
||||
|
||||
if (IsInCutscene)
|
||||
{
|
||||
Mediator.Publish(new CutsceneFrameworkUpdateMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (_condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51])
|
||||
{
|
||||
var zone = _clientState.TerritoryType;
|
||||
if (_lastZone != zone)
|
||||
{
|
||||
_lastZone = zone;
|
||||
if (!_sentBetweenAreas)
|
||||
{
|
||||
_logger.LogDebug("Zone switch/Gpose start");
|
||||
_sentBetweenAreas = true;
|
||||
_playerInfoCache.Clear();
|
||||
Mediator.Publish(new ZoneSwitchStartMessage());
|
||||
Mediator.Publish(new HaltScanMessage(nameof(ConditionFlag.BetweenAreas)));
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (_sentBetweenAreas)
|
||||
{
|
||||
_logger.LogDebug("Zone switch/Gpose end");
|
||||
_sentBetweenAreas = false;
|
||||
Mediator.Publish(new ZoneSwitchEndMessage());
|
||||
Mediator.Publish(new ResumeScanMessage(nameof(ConditionFlag.BetweenAreas)));
|
||||
}
|
||||
|
||||
var localPlayer = _clientState.LocalPlayer;
|
||||
if (localPlayer != null)
|
||||
{
|
||||
_classJobId = localPlayer.ClassJob.RowId;
|
||||
}
|
||||
|
||||
Mediator.Publish(new PriorityFrameworkUpdateMessage());
|
||||
|
||||
if (!IsInCombatOrPerforming)
|
||||
Mediator.Publish(new FrameworkUpdateMessage());
|
||||
|
||||
if (isNormalFrameworkUpdate)
|
||||
return;
|
||||
|
||||
if (localPlayer != null && !IsLoggedIn)
|
||||
{
|
||||
_logger.LogDebug("Logged in");
|
||||
IsLoggedIn = true;
|
||||
_lastZone = _clientState.TerritoryType;
|
||||
Mediator.Publish(new DalamudLoginMessage());
|
||||
}
|
||||
else if (localPlayer == null && IsLoggedIn)
|
||||
{
|
||||
_logger.LogDebug("Logged out");
|
||||
IsLoggedIn = false;
|
||||
Mediator.Publish(new DalamudLogoutMessage());
|
||||
}
|
||||
|
||||
if (IsInCombatOrPerforming)
|
||||
Mediator.Publish(new FrameworkUpdateMessage());
|
||||
|
||||
Mediator.Publish(new DelayedFrameworkUpdateMessage());
|
||||
|
||||
_delayedFrameworkUpdateCheck = DateTime.UtcNow;
|
||||
});
|
||||
}
|
||||
}
|
45
MareSynchronos/Services/Events/Event.cs
Normal file
45
MareSynchronos/Services/Events/Event.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using MareSynchronos.API.Data;
|
||||
|
||||
namespace MareSynchronos.Services.Events;
|
||||
|
||||
public record Event
|
||||
{
|
||||
public DateTime EventTime { get; }
|
||||
public string UID { get; }
|
||||
public string Character { get; }
|
||||
public string EventSource { get; }
|
||||
public EventSeverity EventSeverity { get; }
|
||||
public string Message { get; }
|
||||
|
||||
public Event(string? Character, UserData UserData, string EventSource, EventSeverity EventSeverity, string Message)
|
||||
{
|
||||
EventTime = DateTime.Now;
|
||||
this.UID = UserData.AliasOrUID;
|
||||
this.Character = Character ?? string.Empty;
|
||||
this.EventSource = EventSource;
|
||||
this.EventSeverity = EventSeverity;
|
||||
this.Message = Message;
|
||||
}
|
||||
|
||||
public Event(UserData UserData, string EventSource, EventSeverity EventSeverity, string Message) : this(null, UserData, EventSource, EventSeverity, Message)
|
||||
{
|
||||
}
|
||||
|
||||
public Event(string EventSource, EventSeverity EventSeverity, string Message)
|
||||
: this(new UserData(string.Empty), EventSource, EventSeverity, Message)
|
||||
{
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
if (string.IsNullOrEmpty(UID))
|
||||
return $"{EventTime:HH:mm:ss.fff}\t[{EventSource}]{{{(int)EventSeverity}}}\t{Message}";
|
||||
else
|
||||
{
|
||||
if (string.IsNullOrEmpty(Character))
|
||||
return $"{EventTime:HH:mm:ss.fff}\t[{EventSource}]{{{(int)EventSeverity}}}\t<{UID}> {Message}";
|
||||
else
|
||||
return $"{EventTime:HH:mm:ss.fff}\t[{EventSource}]{{{(int)EventSeverity}}}\t<{UID}\\{Character}> {Message}";
|
||||
}
|
||||
}
|
||||
}
|
113
MareSynchronos/Services/Events/EventAggregator.cs
Normal file
113
MareSynchronos/Services/Events/EventAggregator.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using MareSynchronos.MareConfiguration;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using MareSynchronos.Utils;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MareSynchronos.Services.Events;
|
||||
|
||||
public class EventAggregator : MediatorSubscriberBase, IHostedService
|
||||
{
|
||||
private readonly RollingList<Event> _events = new(500);
|
||||
private readonly SemaphoreSlim _lock = new(1);
|
||||
private readonly string _configDirectory;
|
||||
private readonly ILogger<EventAggregator> _logger;
|
||||
|
||||
public Lazy<List<Event>> EventList { get; private set; }
|
||||
public bool NewEventsAvailable => !EventList.IsValueCreated;
|
||||
public string EventLogFolder => Path.Combine(_configDirectory, "eventlog");
|
||||
private string CurrentLogName => $"{DateTime.Now:yyyy-MM-dd}-events.log";
|
||||
private DateTime _currentTime;
|
||||
|
||||
public EventAggregator(MareConfigService configService, ILogger<EventAggregator> logger, MareMediator mareMediator) : base(logger, mareMediator)
|
||||
{
|
||||
Mediator.Subscribe<EventMessage>(this, (msg) =>
|
||||
{
|
||||
_lock.Wait();
|
||||
try
|
||||
{
|
||||
Logger.LogTrace("Received Event: {evt}", msg.Event.ToString());
|
||||
_events.Add(msg.Event);
|
||||
if (configService.Current.LogEvents)
|
||||
WriteToFile(msg.Event);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
|
||||
RecreateLazy();
|
||||
});
|
||||
|
||||
EventList = CreateEventLazy();
|
||||
_configDirectory = configService.ConfigurationDirectory;
|
||||
_logger = logger;
|
||||
_currentTime = DateTime.Now - TimeSpan.FromDays(1);
|
||||
}
|
||||
|
||||
private void RecreateLazy()
|
||||
{
|
||||
if (!EventList.IsValueCreated) return;
|
||||
|
||||
EventList = CreateEventLazy();
|
||||
}
|
||||
|
||||
private Lazy<List<Event>> CreateEventLazy()
|
||||
{
|
||||
return new Lazy<List<Event>>(() =>
|
||||
{
|
||||
_lock.Wait();
|
||||
try
|
||||
{
|
||||
return [.. _events];
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void WriteToFile(Event receivedEvent)
|
||||
{
|
||||
if (DateTime.Now.Day != _currentTime.Day)
|
||||
{
|
||||
try
|
||||
{
|
||||
_currentTime = DateTime.Now;
|
||||
var filesInDirectory = Directory.EnumerateFiles(EventLogFolder, "*.log");
|
||||
if (filesInDirectory.Skip(10).Any())
|
||||
{
|
||||
File.Delete(filesInDirectory.OrderBy(f => new FileInfo(f).LastWriteTimeUtc).First());
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not delete last events");
|
||||
}
|
||||
}
|
||||
|
||||
var eventLogFile = Path.Combine(EventLogFolder, CurrentLogName);
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(EventLogFolder)) Directory.CreateDirectory(EventLogFolder);
|
||||
File.AppendAllLines(eventLogFile, [receivedEvent.ToString()]);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, $"Could not write to event file {eventLogFile}");
|
||||
}
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Logger.LogInformation("Starting EventAggregatorService");
|
||||
Logger.LogInformation("Started EventAggregatorService");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
8
MareSynchronos/Services/Events/EventSeverity.cs
Normal file
8
MareSynchronos/Services/Events/EventSeverity.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace MareSynchronos.Services.Events;
|
||||
|
||||
public enum EventSeverity
|
||||
{
|
||||
Informational = 0,
|
||||
Warning = 1,
|
||||
Error = 2
|
||||
}
|
144
MareSynchronos/Services/GuiHookService.cs
Normal file
144
MareSynchronos/Services/GuiHookService.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using Dalamud.Game.Gui.NamePlate;
|
||||
using Dalamud.Game.Text.SeStringHandling;
|
||||
using Dalamud.Game.Text.SeStringHandling.Payloads;
|
||||
using Dalamud.Plugin.Services;
|
||||
using MareSynchronos.MareConfiguration;
|
||||
using MareSynchronos.PlayerData.Pairs;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using MareSynchronos.UI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
public class GuiHookService : DisposableMediatorSubscriberBase
|
||||
{
|
||||
private readonly ILogger<GuiHookService> _logger;
|
||||
private readonly DalamudUtilService _dalamudUtil;
|
||||
private readonly MareConfigService _configService;
|
||||
private readonly INamePlateGui _namePlateGui;
|
||||
private readonly IGameConfig _gameConfig;
|
||||
private readonly IPartyList _partyList;
|
||||
private readonly PairManager _pairManager;
|
||||
|
||||
private bool _isModified = false;
|
||||
private bool _namePlateRoleColorsEnabled = false;
|
||||
|
||||
public GuiHookService(ILogger<GuiHookService> logger, DalamudUtilService dalamudUtil, MareMediator mediator, MareConfigService configService,
|
||||
INamePlateGui namePlateGui, IGameConfig gameConfig, IPartyList partyList, PairManager pairManager)
|
||||
: base(logger, mediator)
|
||||
{
|
||||
_logger = logger;
|
||||
_dalamudUtil = dalamudUtil;
|
||||
_configService = configService;
|
||||
_namePlateGui = namePlateGui;
|
||||
_gameConfig = gameConfig;
|
||||
_partyList = partyList;
|
||||
_pairManager = pairManager;
|
||||
|
||||
_namePlateGui.OnNamePlateUpdate += OnNamePlateUpdate;
|
||||
_namePlateGui.RequestRedraw();
|
||||
|
||||
Mediator.Subscribe<DelayedFrameworkUpdateMessage>(this, (_) => GameSettingsCheck());
|
||||
Mediator.Subscribe<PairHandlerVisibleMessage>(this, (_) => RequestRedraw());
|
||||
Mediator.Subscribe<NameplateRedrawMessage>(this, (_) => RequestRedraw());
|
||||
}
|
||||
|
||||
public void RequestRedraw(bool force = false)
|
||||
{
|
||||
if (!_configService.Current.UseNameColors)
|
||||
{
|
||||
if (!_isModified && !force)
|
||||
return;
|
||||
_isModified = false;
|
||||
}
|
||||
|
||||
_ = Task.Run(async () => {
|
||||
await _dalamudUtil.RunOnFrameworkThread(() => _namePlateGui.RequestRedraw()).ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
_namePlateGui.OnNamePlateUpdate -= OnNamePlateUpdate;
|
||||
|
||||
_ = Task.Run(async () => {
|
||||
await _dalamudUtil.RunOnFrameworkThread(() => _namePlateGui.RequestRedraw()).ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
private void OnNamePlateUpdate(INamePlateUpdateContext context, IReadOnlyList<INamePlateUpdateHandler> handlers)
|
||||
{
|
||||
if (!_configService.Current.UseNameColors)
|
||||
return;
|
||||
|
||||
var visibleUsers = _pairManager.GetOnlineUserPairs().Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue);
|
||||
var visibleUsersIds = visibleUsers.Select(u => (ulong)u.PlayerCharacterId).ToHashSet();
|
||||
|
||||
var visibleUsersDict = visibleUsers.ToDictionary(u => (ulong)u.PlayerCharacterId);
|
||||
|
||||
var partyMembers = new nint[_partyList.Count];
|
||||
|
||||
for (int i = 0; i < _partyList.Count; ++i)
|
||||
partyMembers[i] = _partyList[i]?.GameObject?.Address ?? nint.MaxValue;
|
||||
|
||||
foreach (var handler in handlers)
|
||||
{
|
||||
if (handler != null && visibleUsersIds.Contains(handler.GameObjectId))
|
||||
{
|
||||
if (_namePlateRoleColorsEnabled && partyMembers.Contains(handler.GameObject?.Address ?? nint.MaxValue))
|
||||
continue;
|
||||
var pair = visibleUsersDict[handler.GameObjectId];
|
||||
var colors = !pair.IsApplicationBlocked ? _configService.Current.NameColors : _configService.Current.BlockedNameColors;
|
||||
handler.NameParts.TextWrap = (
|
||||
BuildColorStartSeString(colors),
|
||||
BuildColorEndSeString(colors)
|
||||
);
|
||||
_isModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void GameSettingsCheck()
|
||||
{
|
||||
if (!_gameConfig.TryGet(Dalamud.Game.Config.UiConfigOption.NamePlateSetRoleColor, out bool namePlateRoleColorsEnabled))
|
||||
return;
|
||||
|
||||
if (_namePlateRoleColorsEnabled != namePlateRoleColorsEnabled)
|
||||
{
|
||||
_namePlateRoleColorsEnabled = namePlateRoleColorsEnabled;
|
||||
RequestRedraw(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
#region Colored SeString
|
||||
private const byte _colorTypeForeground = 0x13;
|
||||
private const byte _colorTypeGlow = 0x14;
|
||||
|
||||
private static SeString BuildColorStartSeString(DtrEntry.Colors colors)
|
||||
{
|
||||
var ssb = new SeStringBuilder();
|
||||
if (colors.Foreground != default)
|
||||
ssb.Add(BuildColorStartPayload(_colorTypeForeground, colors.Foreground));
|
||||
if (colors.Glow != default)
|
||||
ssb.Add(BuildColorStartPayload(_colorTypeGlow, colors.Glow));
|
||||
return ssb.Build();
|
||||
}
|
||||
|
||||
private static SeString BuildColorEndSeString(DtrEntry.Colors colors)
|
||||
{
|
||||
var ssb = new SeStringBuilder();
|
||||
if (colors.Glow != default)
|
||||
ssb.Add(BuildColorEndPayload(_colorTypeGlow));
|
||||
if (colors.Foreground != default)
|
||||
ssb.Add(BuildColorEndPayload(_colorTypeForeground));
|
||||
return ssb.Build();
|
||||
}
|
||||
|
||||
private static RawPayload BuildColorStartPayload(byte colorType, uint color)
|
||||
=> new(unchecked([0x02, colorType, 0x05, 0xF6, byte.Max((byte)color, 0x01), byte.Max((byte)(color >> 8), 0x01), byte.Max((byte)(color >> 16), 0x01), 0x03]));
|
||||
|
||||
private static RawPayload BuildColorEndPayload(byte colorType)
|
||||
=> new([0x02, colorType, 0x02, 0xEC, 0x03]);
|
||||
#endregion
|
||||
}
|
6
MareSynchronos/Services/MareProfileData.cs
Normal file
6
MareSynchronos/Services/MareProfileData.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
public record MareProfileData(bool IsFlagged, bool IsNSFW, string Base64ProfilePicture, string Description)
|
||||
{
|
||||
public Lazy<byte[]> ImageData { get; } = new Lazy<byte[]>(Convert.FromBase64String(Base64ProfilePicture));
|
||||
}
|
78
MareSynchronos/Services/MareProfileManager.cs
Normal file
78
MareSynchronos/Services/MareProfileManager.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using MareSynchronos.API.Data;
|
||||
using MareSynchronos.API.Data.Comparer;
|
||||
using MareSynchronos.MareConfiguration;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using MareSynchronos.Services.ServerConfiguration;
|
||||
using MareSynchronos.WebAPI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
public class MareProfileManager : MediatorSubscriberBase
|
||||
{
|
||||
private const string _noDescription = "-- User has no description set --";
|
||||
private const string _nsfw = "Profile not displayed - NSFW";
|
||||
private readonly ApiController _apiController;
|
||||
private readonly MareConfigService _mareConfigService;
|
||||
private readonly ServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly ConcurrentDictionary<UserData, MareProfileData> _mareProfiles = new(UserDataComparer.Instance);
|
||||
|
||||
private readonly MareProfileData _defaultProfileData = new(IsFlagged: false, IsNSFW: false, string.Empty, _noDescription);
|
||||
private readonly MareProfileData _loadingProfileData = new(IsFlagged: false, IsNSFW: false, string.Empty, "Loading Data from server...");
|
||||
private readonly MareProfileData _nsfwProfileData = new(IsFlagged: false, IsNSFW: false, string.Empty, _nsfw);
|
||||
|
||||
public MareProfileManager(ILogger<MareProfileManager> logger, MareConfigService mareConfigService,
|
||||
MareMediator mediator, ApiController apiController, ServerConfigurationManager serverConfigurationManager) : base(logger, mediator)
|
||||
{
|
||||
_mareConfigService = mareConfigService;
|
||||
_apiController = apiController;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
|
||||
Mediator.Subscribe<ClearProfileDataMessage>(this, (msg) =>
|
||||
{
|
||||
if (msg.UserData != null)
|
||||
_mareProfiles.Remove(msg.UserData, out _);
|
||||
else
|
||||
_mareProfiles.Clear();
|
||||
});
|
||||
Mediator.Subscribe<DisconnectedMessage>(this, (_) => _mareProfiles.Clear());
|
||||
}
|
||||
|
||||
public MareProfileData GetMareProfile(UserData data)
|
||||
{
|
||||
if (!_mareProfiles.TryGetValue(data, out var profile))
|
||||
{
|
||||
_ = Task.Run(() => GetMareProfileFromService(data));
|
||||
return (_loadingProfileData);
|
||||
}
|
||||
|
||||
return (profile);
|
||||
}
|
||||
|
||||
private async Task GetMareProfileFromService(UserData data)
|
||||
{
|
||||
try
|
||||
{
|
||||
_mareProfiles[data] = _loadingProfileData;
|
||||
var profile = await _apiController.UserGetProfile(new API.Dto.User.UserDto(data)).ConfigureAwait(false);
|
||||
MareProfileData profileData = new(profile.Disabled, profile.IsNSFW ?? false,
|
||||
string.IsNullOrEmpty(profile.ProfilePictureBase64) ? string.Empty : profile.ProfilePictureBase64,
|
||||
string.IsNullOrEmpty(profile.Description) ? _noDescription : profile.Description);
|
||||
if (profileData.IsNSFW && !_mareConfigService.Current.ProfilesAllowNsfw && !string.Equals(_apiController.UID, data.UID, StringComparison.Ordinal))
|
||||
{
|
||||
_mareProfiles[data] = _nsfwProfileData;
|
||||
}
|
||||
else
|
||||
{
|
||||
_mareProfiles[data] = profileData;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// if fails save DefaultProfileData to dict
|
||||
Logger.LogWarning(ex, "Failed to get Profile from service for user {user}", data);
|
||||
_mareProfiles[data] = _defaultProfileData;
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,22 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MareSynchronos.Services.Mediator;
|
||||
|
||||
public abstract class DisposableMediatorSubscriberBase : MediatorSubscriberBase, IDisposable
|
||||
{
|
||||
protected DisposableMediatorSubscriberBase(ILogger logger, MareMediator mediator) : base(logger, mediator)
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(disposing: true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
Logger.LogTrace("Disposing {type} ({this})", GetType().Name, this);
|
||||
UnsubscribeAll();
|
||||
}
|
||||
}
|
6
MareSynchronos/Services/Mediator/IMediatorSubscriber.cs
Normal file
6
MareSynchronos/Services/Mediator/IMediatorSubscriber.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace MareSynchronos.Services.Mediator;
|
||||
|
||||
public interface IMediatorSubscriber
|
||||
{
|
||||
MareMediator Mediator { get; }
|
||||
}
|
222
MareSynchronos/Services/Mediator/MareMediator.cs
Normal file
222
MareSynchronos/Services/Mediator/MareMediator.cs
Normal file
@@ -0,0 +1,222 @@
|
||||
using MareSynchronos.MareConfiguration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
|
||||
namespace MareSynchronos.Services.Mediator;
|
||||
|
||||
public sealed class MareMediator : IHostedService
|
||||
{
|
||||
private readonly Lock _addRemoveLock = new();
|
||||
private readonly ConcurrentDictionary<SubscriberAction, DateTime> _lastErrorTime = [];
|
||||
private readonly ILogger<MareMediator> _logger;
|
||||
private readonly CancellationTokenSource _loopCts = new();
|
||||
private readonly ConcurrentQueue<MessageBase> _messageQueue = new();
|
||||
private readonly PerformanceCollectorService _performanceCollector;
|
||||
private readonly MareConfigService _mareConfigService;
|
||||
private readonly ConcurrentDictionary<(Type, string?), HashSet<SubscriberAction>> _subscriberDict = [];
|
||||
private bool _processQueue = false;
|
||||
private readonly ConcurrentDictionary<(Type, string?), MethodInfo?> _genericExecuteMethods = new();
|
||||
public MareMediator(ILogger<MareMediator> logger, PerformanceCollectorService performanceCollector, MareConfigService mareConfigService)
|
||||
{
|
||||
_logger = logger;
|
||||
_performanceCollector = performanceCollector;
|
||||
_mareConfigService = mareConfigService;
|
||||
}
|
||||
|
||||
public void PrintSubscriberInfo()
|
||||
{
|
||||
foreach (var subscriber in _subscriberDict.SelectMany(c => c.Value.Select(v => v.Subscriber))
|
||||
.DistinctBy(p => p).OrderBy(p => p.GetType().FullName, StringComparer.Ordinal).ToList())
|
||||
{
|
||||
_logger.LogInformation("Subscriber {type}: {sub}", subscriber.GetType().Name, subscriber.ToString());
|
||||
StringBuilder sb = new();
|
||||
sb.Append("=> ");
|
||||
foreach (var item in _subscriberDict.Where(item => item.Value.Any(v => v.Subscriber == subscriber)).ToList())
|
||||
{
|
||||
sb.Append(item.Key.Item1.Name);
|
||||
if (item.Key.Item2 != null)
|
||||
sb.Append($":{item.Key.Item2!}");
|
||||
sb.Append(", ");
|
||||
}
|
||||
|
||||
if (!string.Equals(sb.ToString(), "=> ", StringComparison.Ordinal))
|
||||
_logger.LogInformation("{sb}", sb.ToString());
|
||||
_logger.LogInformation("---");
|
||||
}
|
||||
}
|
||||
|
||||
public void Publish<T>(T message) where T : MessageBase
|
||||
{
|
||||
if (message.KeepThreadContext)
|
||||
{
|
||||
ExecuteMessage(message);
|
||||
}
|
||||
else
|
||||
{
|
||||
_messageQueue.Enqueue(message);
|
||||
}
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting MareMediator");
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
while (!_loopCts.Token.IsCancellationRequested)
|
||||
{
|
||||
while (!_processQueue)
|
||||
{
|
||||
await Task.Delay(100, _loopCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Task.Delay(100, _loopCts.Token).ConfigureAwait(false);
|
||||
|
||||
while (_messageQueue.TryDequeue(out var message))
|
||||
{
|
||||
ExecuteMessage(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_logger.LogInformation("Started MareMediator");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_messageQueue.Clear();
|
||||
_loopCts.Cancel();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Subscribe<T>(IMediatorSubscriber subscriber, Action<T> action) where T : MessageBase
|
||||
{
|
||||
lock (_addRemoveLock)
|
||||
{
|
||||
_subscriberDict.TryAdd((typeof(T), null), []);
|
||||
|
||||
if (!_subscriberDict[(typeof(T), null)].Add(new(subscriber, action)))
|
||||
{
|
||||
throw new InvalidOperationException("Already subscribed");
|
||||
}
|
||||
|
||||
_logger.LogTrace("Subscriber added for message {message}: {sub}", typeof(T).Name, subscriber.GetType().Name);
|
||||
}
|
||||
}
|
||||
|
||||
public void SubscribeKeyed<T>(IMediatorSubscriber subscriber, string key, Action<T> action) where T : MessageBase
|
||||
{
|
||||
lock (_addRemoveLock)
|
||||
{
|
||||
_subscriberDict.TryAdd((typeof(T), key), []);
|
||||
|
||||
if (!_subscriberDict[(typeof(T), key)].Add(new(subscriber, action)))
|
||||
{
|
||||
throw new InvalidOperationException("Already subscribed");
|
||||
}
|
||||
|
||||
_logger.LogTrace("Subscriber added for message {message}:{key}: {sub}", typeof(T).Name, key, subscriber.GetType().Name);
|
||||
}
|
||||
}
|
||||
|
||||
public void Unsubscribe<T>(IMediatorSubscriber subscriber) where T : MessageBase
|
||||
{
|
||||
lock (_addRemoveLock)
|
||||
{
|
||||
if (_subscriberDict.ContainsKey((typeof(T), null)))
|
||||
{
|
||||
_subscriberDict[(typeof(T), null)].RemoveWhere(p => p.Subscriber == subscriber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal void UnsubscribeAll(IMediatorSubscriber subscriber)
|
||||
{
|
||||
lock (_addRemoveLock)
|
||||
{
|
||||
foreach (var kvp in _subscriberDict.Select(k => k.Key))
|
||||
{
|
||||
int unSubbed = _subscriberDict[kvp]?.RemoveWhere(p => p.Subscriber == subscriber) ?? 0;
|
||||
if (unSubbed > 0)
|
||||
{
|
||||
_logger.LogDebug("{sub} unsubscribed from {msg}", subscriber.GetType().Name, kvp.Item1.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteMessage(MessageBase message)
|
||||
{
|
||||
if (!_subscriberDict.TryGetValue((message.GetType(), message.SubscriberKey), out HashSet<SubscriberAction>? subscribers) || subscribers == null || !subscribers.Any()) return;
|
||||
|
||||
List<SubscriberAction> subscribersCopy = [];
|
||||
lock (_addRemoveLock)
|
||||
{
|
||||
subscribersCopy = subscribers?.Where(s => s.Subscriber != null).ToList() ?? [];
|
||||
}
|
||||
|
||||
#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields
|
||||
var msgType = message.GetType();
|
||||
if (!_genericExecuteMethods.TryGetValue((msgType, message.SubscriberKey), out var methodInfo))
|
||||
{
|
||||
_genericExecuteMethods[(msgType, message.SubscriberKey)] = methodInfo = GetType()
|
||||
.GetMethod(nameof(ExecuteReflected), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?
|
||||
.MakeGenericMethod(msgType);
|
||||
}
|
||||
|
||||
methodInfo!.Invoke(this, [subscribersCopy, message]);
|
||||
#pragma warning restore S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields
|
||||
}
|
||||
|
||||
private void ExecuteReflected<T>(List<SubscriberAction> subscribers, T message) where T : MessageBase
|
||||
{
|
||||
foreach (SubscriberAction subscriber in subscribers)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_mareConfigService.Current.LogPerformance)
|
||||
{
|
||||
var isSameThread = message.KeepThreadContext ? "$" : string.Empty;
|
||||
_performanceCollector.LogPerformance(this, $"{isSameThread}Execute>{message.GetType().Name}+{subscriber.Subscriber.GetType().Name}>{subscriber.Subscriber}",
|
||||
() => ((Action<T>)subscriber.Action).Invoke(message));
|
||||
}
|
||||
else
|
||||
{
|
||||
((Action<T>)subscriber.Action).Invoke(message);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_lastErrorTime.TryGetValue(subscriber, out var lastErrorTime) && lastErrorTime.Add(TimeSpan.FromSeconds(10)) > DateTime.UtcNow)
|
||||
continue;
|
||||
|
||||
_logger.LogError(ex.InnerException ?? ex, "Error executing {type} for subscriber {subscriber}",
|
||||
message.GetType().Name, subscriber.Subscriber.GetType().Name);
|
||||
_lastErrorTime[subscriber] = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void StartQueueProcessing()
|
||||
{
|
||||
_logger.LogInformation("Starting Message Queue Processing");
|
||||
_processQueue = true;
|
||||
}
|
||||
|
||||
private sealed class SubscriberAction
|
||||
{
|
||||
public SubscriberAction(IMediatorSubscriber subscriber, object action)
|
||||
{
|
||||
Subscriber = subscriber;
|
||||
Action = action;
|
||||
}
|
||||
|
||||
public object Action { get; }
|
||||
public IMediatorSubscriber Subscriber { get; }
|
||||
}
|
||||
}
|
23
MareSynchronos/Services/Mediator/MediatorSubscriberBase.cs
Normal file
23
MareSynchronos/Services/Mediator/MediatorSubscriberBase.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MareSynchronos.Services.Mediator;
|
||||
|
||||
public abstract class MediatorSubscriberBase : IMediatorSubscriber
|
||||
{
|
||||
protected MediatorSubscriberBase(ILogger logger, MareMediator mediator)
|
||||
{
|
||||
Logger = logger;
|
||||
|
||||
Logger.LogTrace("Creating {type} ({this})", GetType().Name, this);
|
||||
Mediator = mediator;
|
||||
}
|
||||
|
||||
public MareMediator Mediator { get; }
|
||||
protected ILogger Logger { get; }
|
||||
|
||||
protected void UnsubscribeAll()
|
||||
{
|
||||
Logger.LogTrace("Unsubscribing from all for {type} ({this})", GetType().Name, this);
|
||||
Mediator.UnsubscribeAll(this);
|
||||
}
|
||||
}
|
20
MareSynchronos/Services/Mediator/MessageBase.cs
Normal file
20
MareSynchronos/Services/Mediator/MessageBase.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace MareSynchronos.Services.Mediator;
|
||||
|
||||
#pragma warning disable MA0048
|
||||
public abstract record MessageBase
|
||||
{
|
||||
public virtual bool KeepThreadContext => false;
|
||||
public virtual string? SubscriberKey => null;
|
||||
}
|
||||
|
||||
public record SameThreadMessage : MessageBase
|
||||
{
|
||||
public override bool KeepThreadContext => true;
|
||||
}
|
||||
|
||||
public record KeyedMessage(string MessageKey, bool SameThread = false) : MessageBase
|
||||
{
|
||||
public override string? SubscriberKey => MessageKey;
|
||||
public override bool KeepThreadContext => SameThread;
|
||||
}
|
||||
#pragma warning restore MA0048
|
113
MareSynchronos/Services/Mediator/Messages.cs
Normal file
113
MareSynchronos/Services/Mediator/Messages.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using Dalamud.Game.ClientState.Objects.Types;
|
||||
using MareSynchronos.API.Data;
|
||||
using MareSynchronos.API.Dto;
|
||||
using MareSynchronos.API.Dto.CharaData;
|
||||
using MareSynchronos.API.Dto.Group;
|
||||
using MareSynchronos.MareConfiguration.Models;
|
||||
using MareSynchronos.PlayerData.Handlers;
|
||||
using MareSynchronos.PlayerData.Pairs;
|
||||
using MareSynchronos.Services.Events;
|
||||
using MareSynchronos.WebAPI.Files.Models;
|
||||
using System.Numerics;
|
||||
|
||||
namespace MareSynchronos.Services.Mediator;
|
||||
|
||||
#pragma warning disable MA0048 // File name must match type name
|
||||
#pragma warning disable S2094
|
||||
public record SwitchToIntroUiMessage : MessageBase;
|
||||
public record SwitchToMainUiMessage : MessageBase;
|
||||
public record OpenSettingsUiMessage : MessageBase;
|
||||
public record DalamudLoginMessage : MessageBase;
|
||||
public record DalamudLogoutMessage : MessageBase;
|
||||
public record PriorityFrameworkUpdateMessage : SameThreadMessage;
|
||||
public record FrameworkUpdateMessage : SameThreadMessage;
|
||||
public record ClassJobChangedMessage(GameObjectHandler GameObjectHandler) : MessageBase;
|
||||
public record DelayedFrameworkUpdateMessage : SameThreadMessage;
|
||||
public record ZoneSwitchStartMessage : MessageBase;
|
||||
public record ZoneSwitchEndMessage : MessageBase;
|
||||
public record CutsceneStartMessage : MessageBase;
|
||||
public record GposeStartMessage : SameThreadMessage;
|
||||
public record GposeEndMessage : MessageBase;
|
||||
public record CutsceneEndMessage : MessageBase;
|
||||
public record CutsceneFrameworkUpdateMessage : SameThreadMessage;
|
||||
public record ConnectedMessage(ConnectionDto Connection) : MessageBase;
|
||||
public record DisconnectedMessage : SameThreadMessage;
|
||||
public record PenumbraModSettingChangedMessage : MessageBase;
|
||||
public record PenumbraInitializedMessage : MessageBase;
|
||||
public record PenumbraDisposedMessage : MessageBase;
|
||||
public record PenumbraRedrawMessage(IntPtr Address, int ObjTblIdx, bool WasRequested) : SameThreadMessage;
|
||||
public record GlamourerChangedMessage(IntPtr Address) : MessageBase;
|
||||
public record HeelsOffsetMessage : MessageBase;
|
||||
public record PenumbraResourceLoadMessage(IntPtr GameObject, string GamePath, string FilePath) : SameThreadMessage;
|
||||
public record CustomizePlusMessage(nint? Address) : MessageBase;
|
||||
public record HonorificMessage(string NewHonorificTitle) : MessageBase;
|
||||
public record PetNamesReadyMessage : MessageBase;
|
||||
public record PetNamesMessage(string PetNicknamesData) : MessageBase;
|
||||
public record MoodlesMessage(IntPtr Address) : MessageBase;
|
||||
public record HonorificReadyMessage : MessageBase;
|
||||
public record PlayerChangedMessage(CharacterData Data) : MessageBase;
|
||||
public record CharacterChangedMessage(GameObjectHandler GameObjectHandler) : MessageBase;
|
||||
public record TransientResourceChangedMessage(IntPtr Address) : MessageBase;
|
||||
public record HaltScanMessage(string Source) : MessageBase;
|
||||
public record ResumeScanMessage(string Source) : MessageBase;
|
||||
public record NotificationMessage
|
||||
(string Title, string Message, NotificationType Type, TimeSpan? TimeShownOnScreen = null) : MessageBase;
|
||||
public record CreateCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : MessageBase;
|
||||
public record ClearCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : MessageBase;
|
||||
public record CharacterDataCreatedMessage(CharacterData CharacterData) : SameThreadMessage;
|
||||
public record CharacterDataAnalyzedMessage : MessageBase;
|
||||
public record PenumbraStartRedrawMessage(IntPtr Address) : MessageBase;
|
||||
public record PenumbraEndRedrawMessage(IntPtr Address) : MessageBase;
|
||||
public record HubReconnectingMessage(Exception? Exception) : SameThreadMessage;
|
||||
public record HubReconnectedMessage(string? Arg) : SameThreadMessage;
|
||||
public record HubClosedMessage(Exception? Exception) : SameThreadMessage;
|
||||
public record DownloadReadyMessage(Guid RequestId) : MessageBase;
|
||||
public record DownloadStartedMessage(GameObjectHandler DownloadId, Dictionary<string, FileDownloadStatus> DownloadStatus) : MessageBase;
|
||||
public record DownloadFinishedMessage(GameObjectHandler DownloadId) : MessageBase;
|
||||
public record UiToggleMessage(Type UiType) : MessageBase;
|
||||
public record PlayerUploadingMessage(GameObjectHandler Handler, bool IsUploading) : MessageBase;
|
||||
public record ClearProfileDataMessage(UserData? UserData = null) : MessageBase;
|
||||
public record CyclePauseMessage(UserData UserData) : MessageBase;
|
||||
public record PauseMessage(UserData UserData) : MessageBase;
|
||||
public record ProfilePopoutToggle(Pair? Pair) : MessageBase;
|
||||
public record CompactUiChange(Vector2 Size, Vector2 Position) : MessageBase;
|
||||
public record ProfileOpenStandaloneMessage(Pair Pair) : MessageBase;
|
||||
public record RemoveWindowMessage(WindowMediatorSubscriberBase Window) : MessageBase;
|
||||
public record PlayerVisibilityMessage(string Ident, bool IsVisible, bool Invalidate = false) : KeyedMessage(Ident, SameThread: true);
|
||||
public record PairHandlerVisibleMessage(PairHandler Player) : MessageBase;
|
||||
public record OpenReportPopupMessage(Pair PairToReport) : MessageBase;
|
||||
public record OpenBanUserPopupMessage(Pair PairToBan, GroupFullInfoDto GroupFullInfoDto) : MessageBase;
|
||||
public record OpenSyncshellAdminPanel(GroupFullInfoDto GroupInfo) : MessageBase;
|
||||
public record OpenPermissionWindow(Pair Pair) : MessageBase;
|
||||
public record OpenPairAnalysisWindow(Pair Pair) : MessageBase;
|
||||
public record DownloadLimitChangedMessage() : SameThreadMessage;
|
||||
public record CensusUpdateMessage(byte Gender, byte RaceId, byte TribeId) : MessageBase;
|
||||
public record TargetPairMessage(Pair Pair) : MessageBase;
|
||||
public record CombatOrPerformanceStartMessage : MessageBase;
|
||||
public record CombatOrPerformanceEndMessage : MessageBase;
|
||||
public record EventMessage(Event Event) : MessageBase;
|
||||
public record PenumbraDirectoryChangedMessage(string? ModDirectory) : MessageBase;
|
||||
public record PenumbraRedrawCharacterMessage(ICharacter Character) : SameThreadMessage;
|
||||
public record UserChatMsgMessage(SignedChatMessage ChatMsg) : MessageBase;
|
||||
public record GroupChatMsgMessage(GroupDto GroupInfo, SignedChatMessage ChatMsg) : MessageBase;
|
||||
public record RecalculatePerformanceMessage(string? UID) : MessageBase;
|
||||
public record NameplateRedrawMessage : MessageBase;
|
||||
public record HoldPairApplicationMessage(string UID, string Source) : KeyedMessage(UID);
|
||||
public record UnholdPairApplicationMessage(string UID, string Source) : KeyedMessage(UID);
|
||||
public record HoldPairDownloadsMessage(string UID, string Source) : KeyedMessage(UID);
|
||||
public record UnholdPairDownloadsMessage(string UID, string Source) : KeyedMessage(UID);
|
||||
public record PairDataAppliedMessage(string UID, CharacterData? CharacterData) : KeyedMessage(UID);
|
||||
public record PairDataAnalyzedMessage(string UID) : KeyedMessage(UID);
|
||||
public record GameObjectHandlerCreatedMessage(GameObjectHandler GameObjectHandler, bool OwnedObject) : MessageBase;
|
||||
public record GameObjectHandlerDestroyedMessage(GameObjectHandler GameObjectHandler, bool OwnedObject) : MessageBase;
|
||||
public record HaltCharaDataCreation(bool Resume = false) : SameThreadMessage;
|
||||
public record OpenCharaDataHubWithFilterMessage(UserData UserData) : MessageBase;
|
||||
public record GposeLobbyUserJoin(UserData UserData) : MessageBase;
|
||||
public record GPoseLobbyUserLeave(UserData UserData) : MessageBase;
|
||||
public record GPoseLobbyReceiveCharaData(CharaDataDownloadDto CharaDataDownloadDto) : MessageBase;
|
||||
public record GPoseLobbyReceivePoseData(UserData UserData, PoseData PoseData) : MessageBase;
|
||||
public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData) : MessageBase;
|
||||
|
||||
public record PluginChangeMessage(string InternalName, Version Version, bool IsLoaded) : KeyedMessage(InternalName);
|
||||
#pragma warning restore S2094
|
||||
#pragma warning restore MA0048 // File name must match type name
|
@@ -0,0 +1,54 @@
|
||||
using Dalamud.Interface.Windowing;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MareSynchronos.Services.Mediator;
|
||||
|
||||
public abstract class WindowMediatorSubscriberBase : Window, IMediatorSubscriber, IDisposable
|
||||
{
|
||||
protected readonly ILogger _logger;
|
||||
private readonly PerformanceCollectorService _performanceCollectorService;
|
||||
|
||||
protected WindowMediatorSubscriberBase(ILogger logger, MareMediator mediator, string name,
|
||||
PerformanceCollectorService performanceCollectorService) : base(name)
|
||||
{
|
||||
_logger = logger;
|
||||
Mediator = mediator;
|
||||
_performanceCollectorService = performanceCollectorService;
|
||||
_logger.LogTrace("Creating {type}", GetType());
|
||||
|
||||
Mediator.Subscribe<UiToggleMessage>(this, (msg) =>
|
||||
{
|
||||
if (msg.UiType == GetType())
|
||||
{
|
||||
Toggle();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public MareMediator Mediator { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(disposing: true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
public override void Draw()
|
||||
{
|
||||
_performanceCollectorService.LogPerformance(this, $"Draw", DrawInternal);
|
||||
}
|
||||
|
||||
protected abstract void DrawInternal();
|
||||
|
||||
public virtual Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
_logger.LogTrace("Disposing {type}", GetType());
|
||||
|
||||
Mediator.UnsubscribeAll(this);
|
||||
}
|
||||
}
|
226
MareSynchronos/Services/NoSnapService.cs
Normal file
226
MareSynchronos/Services/NoSnapService.cs
Normal file
@@ -0,0 +1,226 @@
|
||||
using Dalamud.Plugin;
|
||||
using MareSynchronos.Interop.Ipc;
|
||||
using MareSynchronos.MareConfiguration.Models;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
public sealed class NoSnapService : IHostedService, IMediatorSubscriber
|
||||
{
|
||||
private record NoSnapConfig
|
||||
{
|
||||
[JsonPropertyName("listOfPlugins")]
|
||||
public string[]? ListOfPlugins { get; set; }
|
||||
}
|
||||
|
||||
private readonly ILogger<NoSnapService> _logger;
|
||||
private readonly IDalamudPluginInterface _pluginInterface;
|
||||
private readonly Dictionary<string, bool> _listOfPlugins = new(StringComparer.Ordinal)
|
||||
{
|
||||
["Snapper"] = false,
|
||||
["Snappy"] = false,
|
||||
["Meddle.Plugin"] = false,
|
||||
};
|
||||
private static readonly HashSet<int> _gposers = new();
|
||||
private static readonly HashSet<string> _gposersNamed = new(StringComparer.Ordinal);
|
||||
private readonly IHostApplicationLifetime _hostApplicationLifetime;
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
private readonly IpcManager _ipcManager;
|
||||
private readonly RemoteConfigurationService _remoteConfig;
|
||||
|
||||
public static bool AnyLoaded { get; private set; } = false;
|
||||
public static string ActivePlugins { get; private set; } = string.Empty;
|
||||
|
||||
public MareMediator Mediator { get; init; }
|
||||
|
||||
public NoSnapService(ILogger<NoSnapService> logger, IDalamudPluginInterface pluginInterface, MareMediator mediator,
|
||||
IHostApplicationLifetime hostApplicationLifetime, DalamudUtilService dalamudUtilService, IpcManager ipcManager,
|
||||
RemoteConfigurationService remoteConfig)
|
||||
{
|
||||
_logger = logger;
|
||||
_pluginInterface = pluginInterface;
|
||||
Mediator = mediator;
|
||||
_hostApplicationLifetime = hostApplicationLifetime;
|
||||
_dalamudUtilService = dalamudUtilService;
|
||||
_ipcManager = ipcManager;
|
||||
_remoteConfig = remoteConfig;
|
||||
|
||||
Mediator.Subscribe<GposeEndMessage>(this, msg => ClearGposeList());
|
||||
Mediator.Subscribe<CutsceneEndMessage>(this, msg => ClearGposeList());
|
||||
}
|
||||
|
||||
public void AddGposer(int objectIndex)
|
||||
{
|
||||
if (AnyLoaded || _hostApplicationLifetime.ApplicationStopping.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogTrace("Immediately reverting object index {id}", objectIndex);
|
||||
RevertAndRedraw(objectIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogTrace("Registering gposer object index {id}", objectIndex);
|
||||
lock (_gposers)
|
||||
_gposers.Add(objectIndex);
|
||||
}
|
||||
|
||||
public void RemoveGposer(int objectIndex)
|
||||
{
|
||||
_logger.LogTrace("Un-registering gposer object index {id}", objectIndex);
|
||||
lock (_gposers)
|
||||
_gposers.Remove(objectIndex);
|
||||
}
|
||||
|
||||
public void AddGposerNamed(string name)
|
||||
{
|
||||
if (AnyLoaded || _hostApplicationLifetime.ApplicationStopping.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogTrace("Immediately reverting {name}", name);
|
||||
RevertAndRedraw(name);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogTrace("Registering gposer {name}", name);
|
||||
lock (_gposers)
|
||||
_gposersNamed.Add(name);
|
||||
}
|
||||
|
||||
private void ClearGposeList()
|
||||
{
|
||||
if (_gposers.Count > 0 || _gposersNamed.Count > 0)
|
||||
_logger.LogTrace("Clearing gposer list");
|
||||
lock (_gposers)
|
||||
_gposers.Clear();
|
||||
lock (_gposersNamed)
|
||||
_gposersNamed.Clear();
|
||||
}
|
||||
|
||||
private void RevertAndRedraw(int objIndex, Guid applicationId = default)
|
||||
{
|
||||
if (applicationId == default)
|
||||
applicationId = Guid.NewGuid();
|
||||
|
||||
try
|
||||
{
|
||||
_ipcManager.Glamourer.RevertNow(_logger, applicationId, objIndex);
|
||||
_ipcManager.Penumbra.RedrawNow(_logger, applicationId, objIndex);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private void RevertAndRedraw(string name, Guid applicationId = default)
|
||||
{
|
||||
if (applicationId == default)
|
||||
applicationId = Guid.NewGuid();
|
||||
|
||||
try
|
||||
{
|
||||
_ipcManager.Glamourer.RevertByNameNow(_logger, applicationId, name);
|
||||
var addr = _dalamudUtilService.GetPlayerCharacterFromCachedTableByName(name);
|
||||
if (addr != 0)
|
||||
{
|
||||
var obj = _dalamudUtilService.CreateGameObject(addr);
|
||||
if (obj != null)
|
||||
_ipcManager.Penumbra.RedrawNow(_logger, applicationId, obj.ObjectIndex);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private void RevertGposers()
|
||||
{
|
||||
List<int>? gposersList = null;
|
||||
List<string>? gposersList2 = null;
|
||||
|
||||
lock (_gposers)
|
||||
{
|
||||
if (_gposers.Count > 0)
|
||||
{
|
||||
gposersList = _gposers.ToList();
|
||||
_gposers.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
lock (_gposersNamed)
|
||||
{
|
||||
if (_gposersNamed.Count > 0)
|
||||
{
|
||||
gposersList2 = _gposersNamed.ToList();
|
||||
_gposersNamed.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (gposersList == null && gposersList2 == null)
|
||||
return;
|
||||
|
||||
_logger.LogInformation("Reverting gposers");
|
||||
|
||||
_dalamudUtilService.RunOnFrameworkThread(() =>
|
||||
{
|
||||
Guid applicationId = Guid.NewGuid();
|
||||
|
||||
foreach (var gposer in gposersList ?? [])
|
||||
RevertAndRedraw(gposer, applicationId);
|
||||
|
||||
foreach (var gposerName in gposersList2 ?? [])
|
||||
RevertAndRedraw(gposerName, applicationId);
|
||||
}).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var config = await _remoteConfig.GetConfigAsync<NoSnapConfig>("noSnap").ConfigureAwait(false) ?? new();
|
||||
|
||||
if (config.ListOfPlugins != null)
|
||||
{
|
||||
_listOfPlugins.Clear();
|
||||
foreach (var pluginName in config.ListOfPlugins)
|
||||
_listOfPlugins.TryAdd(pluginName, value: false);
|
||||
}
|
||||
|
||||
foreach (var pluginName in _listOfPlugins.Keys)
|
||||
{
|
||||
_listOfPlugins[pluginName] = PluginWatcherService.GetInitialPluginState(_pluginInterface, pluginName)?.IsLoaded ?? false;
|
||||
Mediator.SubscribeKeyed<PluginChangeMessage>(this, pluginName, (msg) =>
|
||||
{
|
||||
_listOfPlugins[pluginName] = msg.IsLoaded;
|
||||
_logger.LogDebug("{pluginName} isLoaded = {isLoaded}", pluginName, msg.IsLoaded);
|
||||
Update();
|
||||
});
|
||||
}
|
||||
|
||||
Update();
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
RevertGposers();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
bool anyLoadedNow = _listOfPlugins.Values.Any(p => p);
|
||||
|
||||
if (AnyLoaded != anyLoadedNow)
|
||||
{
|
||||
AnyLoaded = anyLoadedNow;
|
||||
Mediator.Publish(new RecalculatePerformanceMessage(null));
|
||||
|
||||
if (AnyLoaded)
|
||||
{
|
||||
RevertGposers();
|
||||
var pluginList = string.Join(", ", _listOfPlugins.Where(p => p.Value).Select(p => p.Key));
|
||||
Mediator.Publish(new NotificationMessage("Incompatible plugin loaded", $"Synced player appearances will not apply until incompatible plugins are disabled: {pluginList}.",
|
||||
NotificationType.Error));
|
||||
ActivePlugins = pluginList;
|
||||
}
|
||||
else
|
||||
{
|
||||
ActivePlugins = string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
141
MareSynchronos/Services/NotificationService.cs
Normal file
141
MareSynchronos/Services/NotificationService.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
using Dalamud.Game.Text.SeStringHandling;
|
||||
using Dalamud.Interface.ImGuiNotification;
|
||||
using Dalamud.Plugin.Services;
|
||||
using MareSynchronos.MareConfiguration;
|
||||
using MareSynchronos.MareConfiguration.Models;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NotificationType = MareSynchronos.MareConfiguration.Models.NotificationType;
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
public class NotificationService : DisposableMediatorSubscriberBase, IHostedService
|
||||
{
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
private readonly INotificationManager _notificationManager;
|
||||
private readonly IChatGui _chatGui;
|
||||
private readonly MareConfigService _configurationService;
|
||||
|
||||
public NotificationService(ILogger<NotificationService> logger, MareMediator mediator,
|
||||
DalamudUtilService dalamudUtilService,
|
||||
INotificationManager notificationManager,
|
||||
IChatGui chatGui, MareConfigService configurationService) : base(logger, mediator)
|
||||
{
|
||||
_dalamudUtilService = dalamudUtilService;
|
||||
_notificationManager = notificationManager;
|
||||
_chatGui = chatGui;
|
||||
_configurationService = configurationService;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Mediator.Subscribe<NotificationMessage>(this, ShowNotification);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void PrintErrorChat(string? message)
|
||||
{
|
||||
SeStringBuilder se = new SeStringBuilder().AddText("[ElezenSync] Error: " + message);
|
||||
_chatGui.PrintError(se.BuiltString);
|
||||
}
|
||||
|
||||
private void PrintInfoChat(string? message)
|
||||
{
|
||||
SeStringBuilder se = new SeStringBuilder().AddText("[ElezenSync] Info: ").AddItalics(message ?? string.Empty);
|
||||
_chatGui.Print(se.BuiltString);
|
||||
}
|
||||
|
||||
private void PrintWarnChat(string? message)
|
||||
{
|
||||
SeStringBuilder se = new SeStringBuilder().AddText("[ElezenSync] ").AddUiForeground("Warning: " + (message ?? string.Empty), 31).AddUiForegroundOff();
|
||||
_chatGui.Print(se.BuiltString);
|
||||
}
|
||||
|
||||
private void ShowChat(NotificationMessage msg)
|
||||
{
|
||||
switch (msg.Type)
|
||||
{
|
||||
case NotificationType.Info:
|
||||
PrintInfoChat(msg.Message);
|
||||
break;
|
||||
|
||||
case NotificationType.Warning:
|
||||
PrintWarnChat(msg.Message);
|
||||
break;
|
||||
|
||||
case NotificationType.Error:
|
||||
PrintErrorChat(msg.Message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowNotification(NotificationMessage msg)
|
||||
{
|
||||
Logger.LogInformation("{msg}", msg.ToString());
|
||||
|
||||
if (!_dalamudUtilService.IsLoggedIn) return;
|
||||
|
||||
switch (msg.Type)
|
||||
{
|
||||
case NotificationType.Info:
|
||||
ShowNotificationLocationBased(msg, _configurationService.Current.InfoNotification);
|
||||
break;
|
||||
|
||||
case NotificationType.Warning:
|
||||
ShowNotificationLocationBased(msg, _configurationService.Current.WarningNotification);
|
||||
break;
|
||||
|
||||
case NotificationType.Error:
|
||||
ShowNotificationLocationBased(msg, _configurationService.Current.ErrorNotification);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowNotificationLocationBased(NotificationMessage msg, NotificationLocation location)
|
||||
{
|
||||
switch (location)
|
||||
{
|
||||
case NotificationLocation.Toast:
|
||||
ShowToast(msg);
|
||||
break;
|
||||
|
||||
case NotificationLocation.Chat:
|
||||
ShowChat(msg);
|
||||
break;
|
||||
|
||||
case NotificationLocation.Both:
|
||||
ShowToast(msg);
|
||||
ShowChat(msg);
|
||||
break;
|
||||
|
||||
case NotificationLocation.Nowhere:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowToast(NotificationMessage msg)
|
||||
{
|
||||
Dalamud.Interface.ImGuiNotification.NotificationType dalamudType = msg.Type switch
|
||||
{
|
||||
NotificationType.Error => Dalamud.Interface.ImGuiNotification.NotificationType.Error,
|
||||
NotificationType.Warning => Dalamud.Interface.ImGuiNotification.NotificationType.Warning,
|
||||
NotificationType.Info => Dalamud.Interface.ImGuiNotification.NotificationType.Info,
|
||||
_ => Dalamud.Interface.ImGuiNotification.NotificationType.Info
|
||||
};
|
||||
|
||||
_notificationManager.AddNotification(new Notification()
|
||||
{
|
||||
Content = msg.Message ?? string.Empty,
|
||||
Title = msg.Title,
|
||||
Type = dalamudType,
|
||||
Minimized = false,
|
||||
InitialDuration = msg.TimeShownOnScreen ?? TimeSpan.FromSeconds(3)
|
||||
});
|
||||
}
|
||||
}
|
214
MareSynchronos/Services/PairAnalyzer.cs
Normal file
214
MareSynchronos/Services/PairAnalyzer.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
using MareSynchronos.API.Data;
|
||||
using MareSynchronos.API.Data.Enum;
|
||||
using MareSynchronos.FileCache;
|
||||
using MareSynchronos.PlayerData.Pairs;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using MareSynchronos.UI;
|
||||
using MareSynchronos.Utils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
public sealed class PairAnalyzer : DisposableMediatorSubscriberBase
|
||||
{
|
||||
private readonly FileCacheManager _fileCacheManager;
|
||||
private readonly XivDataAnalyzer _xivDataAnalyzer;
|
||||
private CancellationTokenSource? _analysisCts;
|
||||
private CancellationTokenSource _baseAnalysisCts = new();
|
||||
private string _lastDataHash = string.Empty;
|
||||
|
||||
public PairAnalyzer(ILogger<PairAnalyzer> logger, Pair pair, MareMediator mediator, FileCacheManager fileCacheManager, XivDataAnalyzer modelAnalyzer)
|
||||
: base(logger, mediator)
|
||||
{
|
||||
Pair = pair;
|
||||
#if DEBUG
|
||||
Mediator.SubscribeKeyed<PairDataAppliedMessage>(this, pair.UserData.UID, (msg) =>
|
||||
{
|
||||
_baseAnalysisCts = _baseAnalysisCts.CancelRecreate();
|
||||
var token = _baseAnalysisCts.Token;
|
||||
if (msg.CharacterData != null)
|
||||
{
|
||||
_ = BaseAnalysis(msg.CharacterData, token);
|
||||
}
|
||||
else
|
||||
{
|
||||
LastAnalysis.Clear();
|
||||
_lastDataHash = string.Empty;
|
||||
}
|
||||
});
|
||||
#endif
|
||||
_fileCacheManager = fileCacheManager;
|
||||
_xivDataAnalyzer = modelAnalyzer;
|
||||
|
||||
#if DEBUG
|
||||
var lastReceivedData = pair.LastReceivedCharacterData;
|
||||
if (lastReceivedData != null)
|
||||
_ = BaseAnalysis(lastReceivedData, _baseAnalysisCts.Token);
|
||||
#endif
|
||||
}
|
||||
|
||||
public Pair Pair { get; init; }
|
||||
public int CurrentFile { get; internal set; }
|
||||
public bool IsAnalysisRunning => _analysisCts != null;
|
||||
public int TotalFiles { get; internal set; }
|
||||
internal Dictionary<ObjectKind, Dictionary<string, CharacterAnalyzer.FileDataEntry>> LastAnalysis { get; } = [];
|
||||
internal string LastPlayerName { get; set; } = string.Empty;
|
||||
|
||||
public void CancelAnalyze()
|
||||
{
|
||||
_analysisCts?.CancelDispose();
|
||||
_analysisCts = null;
|
||||
}
|
||||
|
||||
public async Task ComputeAnalysis(bool print = true, bool recalculate = false)
|
||||
{
|
||||
Logger.LogDebug("=== Calculating Character Analysis ===");
|
||||
|
||||
_analysisCts = _analysisCts?.CancelRecreate() ?? new();
|
||||
|
||||
var cancelToken = _analysisCts.Token;
|
||||
|
||||
var allFiles = LastAnalysis.SelectMany(v => v.Value.Select(d => d.Value)).ToList();
|
||||
if (allFiles.Exists(c => !c.IsComputed || recalculate))
|
||||
{
|
||||
var remaining = allFiles.Where(c => !c.IsComputed || recalculate).ToList();
|
||||
TotalFiles = remaining.Count;
|
||||
CurrentFile = 1;
|
||||
Logger.LogDebug("=== Computing {amount} remaining files ===", remaining.Count);
|
||||
|
||||
Mediator.Publish(new HaltScanMessage(nameof(PairAnalyzer)));
|
||||
try
|
||||
{
|
||||
foreach (var file in remaining)
|
||||
{
|
||||
Logger.LogDebug("Computing file {file}", file.FilePaths[0]);
|
||||
await file.ComputeSizes(_fileCacheManager, cancelToken, ignoreCacheEntries: false).ConfigureAwait(false);
|
||||
CurrentFile++;
|
||||
}
|
||||
|
||||
_fileCacheManager.WriteOutFullCsv();
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to analyze files");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Mediator.Publish(new ResumeScanMessage(nameof(PairAnalyzer)));
|
||||
}
|
||||
}
|
||||
|
||||
LastPlayerName = Pair.PlayerName ?? string.Empty;
|
||||
Mediator.Publish(new PairDataAnalyzedMessage(Pair.UserData.UID));
|
||||
|
||||
_analysisCts.CancelDispose();
|
||||
_analysisCts = null;
|
||||
|
||||
if (print) PrintAnalysis();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (!disposing) return;
|
||||
|
||||
_analysisCts?.CancelDispose();
|
||||
_baseAnalysisCts.CancelDispose();
|
||||
}
|
||||
|
||||
private async Task BaseAnalysis(CharacterData charaData, CancellationToken token)
|
||||
{
|
||||
if (string.Equals(charaData.DataHash.Value, _lastDataHash, StringComparison.Ordinal)) return;
|
||||
|
||||
LastAnalysis.Clear();
|
||||
|
||||
foreach (var obj in charaData.FileReplacements)
|
||||
{
|
||||
Dictionary<string, CharacterAnalyzer.FileDataEntry> data = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var fileEntry in obj.Value)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var fileCacheEntries = _fileCacheManager.GetAllFileCachesByHash(fileEntry.Hash, ignoreCacheEntries: false, validate: false).ToList();
|
||||
if (fileCacheEntries.Count == 0) continue;
|
||||
|
||||
var filePath = fileCacheEntries[^1].ResolvedFilepath;
|
||||
FileInfo fi = new(filePath);
|
||||
string ext = "unk?";
|
||||
try
|
||||
{
|
||||
ext = fi.Extension[1..];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Could not identify extension for {path}", filePath);
|
||||
}
|
||||
|
||||
var tris = await Task.Run(() => _xivDataAnalyzer.GetTrianglesByHash(fileEntry.Hash)).ConfigureAwait(false);
|
||||
|
||||
foreach (var entry in fileCacheEntries)
|
||||
{
|
||||
data[fileEntry.Hash] = new CharacterAnalyzer.FileDataEntry(fileEntry.Hash, ext,
|
||||
[.. fileEntry.GamePaths],
|
||||
fileCacheEntries.Select(c => c.ResolvedFilepath).Distinct(StringComparer.Ordinal).ToList(),
|
||||
entry.Size > 0 ? entry.Size.Value : 0,
|
||||
entry.CompressedSize > 0 ? entry.CompressedSize.Value : 0,
|
||||
tris);
|
||||
}
|
||||
}
|
||||
|
||||
LastAnalysis[obj.Key] = data;
|
||||
}
|
||||
|
||||
Mediator.Publish(new PairDataAnalyzedMessage(Pair.UserData.UID));
|
||||
|
||||
_lastDataHash = charaData.DataHash.Value;
|
||||
}
|
||||
|
||||
private void PrintAnalysis()
|
||||
{
|
||||
if (LastAnalysis.Count == 0) return;
|
||||
foreach (var kvp in LastAnalysis)
|
||||
{
|
||||
int fileCounter = 1;
|
||||
int totalFiles = kvp.Value.Count;
|
||||
Logger.LogInformation("=== Analysis for {uid}:{obj} ===", Pair.UserData.UID, kvp.Key);
|
||||
|
||||
foreach (var entry in kvp.Value.OrderBy(b => b.Value.GamePaths.OrderBy(p => p, StringComparer.Ordinal).First(), StringComparer.Ordinal))
|
||||
{
|
||||
Logger.LogInformation("File {x}/{y}: {hash}", fileCounter++, totalFiles, entry.Key);
|
||||
foreach (var path in entry.Value.GamePaths)
|
||||
{
|
||||
Logger.LogInformation(" Game Path: {path}", path);
|
||||
}
|
||||
if (entry.Value.FilePaths.Count > 1) Logger.LogInformation(" Multiple fitting files detected for {key}", entry.Key);
|
||||
foreach (var filePath in entry.Value.FilePaths)
|
||||
{
|
||||
Logger.LogInformation(" File Path: {path}", filePath);
|
||||
}
|
||||
Logger.LogInformation(" Size: {size}, Compressed: {compressed}", UiSharedService.ByteToString(entry.Value.OriginalSize),
|
||||
UiSharedService.ByteToString(entry.Value.CompressedSize));
|
||||
}
|
||||
}
|
||||
foreach (var kvp in LastAnalysis)
|
||||
{
|
||||
Logger.LogInformation("=== Detailed summary by file type for {obj} ===", kvp.Key);
|
||||
foreach (var entry in kvp.Value.Select(v => v.Value).GroupBy(v => v.FileType, StringComparer.Ordinal))
|
||||
{
|
||||
Logger.LogInformation("{ext} files: {count}, size extracted: {size}, size compressed: {sizeComp}", entry.Key, entry.Count(),
|
||||
UiSharedService.ByteToString(entry.Sum(v => v.OriginalSize)), UiSharedService.ByteToString(entry.Sum(v => v.CompressedSize)));
|
||||
}
|
||||
Logger.LogInformation("=== Total summary for {obj} ===", kvp.Key);
|
||||
Logger.LogInformation("Total files: {count}, size extracted: {size}, size compressed: {sizeComp}", kvp.Value.Count,
|
||||
UiSharedService.ByteToString(kvp.Value.Sum(v => v.Value.OriginalSize)), UiSharedService.ByteToString(kvp.Value.Sum(v => v.Value.CompressedSize)));
|
||||
}
|
||||
|
||||
Logger.LogInformation("=== Total summary for all currently present objects ===");
|
||||
Logger.LogInformation("Total files: {count}, size extracted: {size}, size compressed: {sizeComp}",
|
||||
LastAnalysis.Values.Sum(v => v.Values.Count),
|
||||
UiSharedService.ByteToString(LastAnalysis.Values.Sum(c => c.Values.Sum(v => v.OriginalSize))),
|
||||
UiSharedService.ByteToString(LastAnalysis.Values.Sum(c => c.Values.Sum(v => v.CompressedSize))));
|
||||
}
|
||||
}
|
199
MareSynchronos/Services/PerformanceCollectorService.cs
Normal file
199
MareSynchronos/Services/PerformanceCollectorService.cs
Normal file
@@ -0,0 +1,199 @@
|
||||
using MareSynchronos.MareConfiguration;
|
||||
using MareSynchronos.Utils;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
public sealed class PerformanceCollectorService : IHostedService
|
||||
{
|
||||
private const string _counterSplit = "=>";
|
||||
private readonly ILogger<PerformanceCollectorService> _logger;
|
||||
private readonly MareConfigService _mareConfigService;
|
||||
public ConcurrentDictionary<string, RollingList<(TimeOnly, long)>> PerformanceCounters { get; } = new(StringComparer.Ordinal);
|
||||
private readonly CancellationTokenSource _periodicLogPruneTaskCts = new();
|
||||
|
||||
public PerformanceCollectorService(ILogger<PerformanceCollectorService> logger, MareConfigService mareConfigService)
|
||||
{
|
||||
_logger = logger;
|
||||
_mareConfigService = mareConfigService;
|
||||
}
|
||||
|
||||
public T LogPerformance<T>(object sender, MareInterpolatedStringHandler counterName, Func<T> func, int maxEntries = 10000)
|
||||
{
|
||||
if (!_mareConfigService.Current.LogPerformance) return func.Invoke();
|
||||
|
||||
string cn = sender.GetType().Name + _counterSplit + counterName.BuildMessage();
|
||||
|
||||
if (!PerformanceCounters.TryGetValue(cn, out var list))
|
||||
{
|
||||
list = PerformanceCounters[cn] = new(maxEntries);
|
||||
}
|
||||
|
||||
var dt = DateTime.UtcNow.Ticks;
|
||||
try
|
||||
{
|
||||
return func.Invoke();
|
||||
}
|
||||
finally
|
||||
{
|
||||
var elapsed = DateTime.UtcNow.Ticks - dt;
|
||||
#if DEBUG
|
||||
if (TimeSpan.FromTicks(elapsed) > TimeSpan.FromMilliseconds(10))
|
||||
_logger.LogWarning(">10ms spike on {counterName}: {time}", cn, TimeSpan.FromTicks(elapsed));
|
||||
#endif
|
||||
list.Add((TimeOnly.FromDateTime(DateTime.Now), elapsed));
|
||||
}
|
||||
}
|
||||
|
||||
public void LogPerformance(object sender, MareInterpolatedStringHandler counterName, Action act, int maxEntries = 10000)
|
||||
{
|
||||
if (!_mareConfigService.Current.LogPerformance) { act.Invoke(); return; }
|
||||
|
||||
var cn = sender.GetType().Name + _counterSplit + counterName.BuildMessage();
|
||||
|
||||
if (!PerformanceCounters.TryGetValue(cn, out var list))
|
||||
{
|
||||
list = PerformanceCounters[cn] = new(maxEntries);
|
||||
}
|
||||
|
||||
var dt = DateTime.UtcNow.Ticks;
|
||||
try
|
||||
{
|
||||
act.Invoke();
|
||||
}
|
||||
finally
|
||||
{
|
||||
var elapsed = DateTime.UtcNow.Ticks - dt;
|
||||
#if DEBUG
|
||||
if (TimeSpan.FromTicks(elapsed) > TimeSpan.FromMilliseconds(10))
|
||||
_logger.LogWarning(">10ms spike on {counterName}: {time}", cn, TimeSpan.FromTicks(elapsed));
|
||||
#endif
|
||||
list.Add(new(TimeOnly.FromDateTime(DateTime.Now), elapsed));
|
||||
}
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting PerformanceCollectorService");
|
||||
_ = Task.Run(PeriodicLogPrune, _periodicLogPruneTaskCts.Token);
|
||||
_logger.LogInformation("Started PerformanceCollectorService");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_periodicLogPruneTaskCts.Cancel();
|
||||
_periodicLogPruneTaskCts.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal void PrintPerformanceStats(int limitBySeconds = 0)
|
||||
{
|
||||
if (!_mareConfigService.Current.LogPerformance)
|
||||
{
|
||||
_logger.LogWarning("Performance counters are disabled");
|
||||
}
|
||||
|
||||
StringBuilder sb = new();
|
||||
if (limitBySeconds > 0)
|
||||
{
|
||||
sb.AppendLine($"Performance Metrics over the past {limitBySeconds} seconds of each counter");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine("Performance metrics over total lifetime of each counter");
|
||||
}
|
||||
var data = PerformanceCounters.ToList();
|
||||
var longestCounterName = data.OrderByDescending(d => d.Key.Length).First().Key.Length + 2;
|
||||
sb.Append("-Last".PadRight(15, '-'));
|
||||
sb.Append('|');
|
||||
sb.Append("-Max".PadRight(15, '-'));
|
||||
sb.Append('|');
|
||||
sb.Append("-Average".PadRight(15, '-'));
|
||||
sb.Append('|');
|
||||
sb.Append("-Last Update".PadRight(15, '-'));
|
||||
sb.Append('|');
|
||||
sb.Append("-Entries".PadRight(10, '-'));
|
||||
sb.Append('|');
|
||||
sb.Append("-Counter Name".PadRight(longestCounterName, '-'));
|
||||
sb.AppendLine();
|
||||
var orderedData = data.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var previousCaller = orderedData[0].Key.Split(_counterSplit, StringSplitOptions.RemoveEmptyEntries)[0];
|
||||
foreach (var entry in orderedData)
|
||||
{
|
||||
var newCaller = entry.Key.Split(_counterSplit, StringSplitOptions.RemoveEmptyEntries)[0];
|
||||
if (!string.Equals(previousCaller, newCaller, StringComparison.Ordinal))
|
||||
{
|
||||
DrawSeparator(sb, longestCounterName);
|
||||
}
|
||||
|
||||
var pastEntries = limitBySeconds > 0 ? entry.Value.Where(e => e.Item1.AddMinutes(limitBySeconds / 60.0d) >= TimeOnly.FromDateTime(DateTime.Now)).ToList() : [.. entry.Value];
|
||||
|
||||
if (pastEntries.Any())
|
||||
{
|
||||
sb.Append((" " + TimeSpan.FromTicks(pastEntries.LastOrDefault() == default ? 0 : pastEntries.Last().Item2).TotalMilliseconds.ToString("0.00000", CultureInfo.InvariantCulture)).PadRight(15));
|
||||
sb.Append('|');
|
||||
sb.Append((" " + TimeSpan.FromTicks(pastEntries.Max(m => m.Item2)).TotalMilliseconds.ToString("0.00000", CultureInfo.InvariantCulture)).PadRight(15));
|
||||
sb.Append('|');
|
||||
sb.Append((" " + TimeSpan.FromTicks((long)pastEntries.Average(m => m.Item2)).TotalMilliseconds.ToString("0.00000", CultureInfo.InvariantCulture)).PadRight(15));
|
||||
sb.Append('|');
|
||||
sb.Append((" " + (pastEntries.LastOrDefault() == default ? "-" : pastEntries.Last().Item1.ToString("HH:mm:ss.ffff", CultureInfo.InvariantCulture))).PadRight(15, ' '));
|
||||
sb.Append('|');
|
||||
sb.Append((" " + pastEntries.Count).PadRight(10));
|
||||
sb.Append('|');
|
||||
sb.Append(' ').Append(entry.Key);
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
previousCaller = newCaller;
|
||||
}
|
||||
|
||||
DrawSeparator(sb, longestCounterName);
|
||||
|
||||
_logger.LogInformation("{perf}", sb.ToString());
|
||||
}
|
||||
|
||||
private static void DrawSeparator(StringBuilder sb, int longestCounterName)
|
||||
{
|
||||
sb.Append("".PadRight(15, '-'));
|
||||
sb.Append('+');
|
||||
sb.Append("".PadRight(15, '-'));
|
||||
sb.Append('+');
|
||||
sb.Append("".PadRight(15, '-'));
|
||||
sb.Append('+');
|
||||
sb.Append("".PadRight(15, '-'));
|
||||
sb.Append('+');
|
||||
sb.Append("".PadRight(10, '-'));
|
||||
sb.Append('+');
|
||||
sb.Append("".PadRight(longestCounterName, '-'));
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private async Task PeriodicLogPrune()
|
||||
{
|
||||
while (!_periodicLogPruneTaskCts.Token.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(10), _periodicLogPruneTaskCts.Token).ConfigureAwait(false);
|
||||
|
||||
foreach (var entries in PerformanceCounters.ToList())
|
||||
{
|
||||
try
|
||||
{
|
||||
var last = entries.Value.ToList().Last();
|
||||
if (last.Item1.AddMinutes(10) < TimeOnly.FromDateTime(DateTime.Now) && !PerformanceCounters.TryRemove(entries.Key, out _))
|
||||
{
|
||||
_logger.LogDebug("Could not remove performance counter {counter}", entries.Key);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogWarning(e, "Error removing performance counter {counter}", entries.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
330
MareSynchronos/Services/PlayerPerformanceService.cs
Normal file
330
MareSynchronos/Services/PlayerPerformanceService.cs
Normal file
@@ -0,0 +1,330 @@
|
||||
using MareSynchronos.API.Data;
|
||||
using MareSynchronos.FileCache;
|
||||
using MareSynchronos.MareConfiguration;
|
||||
using MareSynchronos.PlayerData.Handlers;
|
||||
using MareSynchronos.Services.Events;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using MareSynchronos.Services.ServerConfiguration;
|
||||
using MareSynchronos.UI;
|
||||
using MareSynchronos.WebAPI.Files.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
public class PlayerPerformanceService : DisposableMediatorSubscriberBase
|
||||
{
|
||||
// Limits that will still be enforced when no limits are enabled
|
||||
public const int MaxVRAMUsageThreshold = 2000; // 2GB
|
||||
public const int MaxTriUsageThreshold = 2000000; // 2 million triangles
|
||||
|
||||
private readonly FileCacheManager _fileCacheManager;
|
||||
private readonly XivDataAnalyzer _xivDataAnalyzer;
|
||||
private readonly ILogger<PlayerPerformanceService> _logger;
|
||||
private readonly MareMediator _mediator;
|
||||
private readonly ServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
|
||||
private readonly Dictionary<string, bool> _warnedForPlayers = new(StringComparer.Ordinal);
|
||||
|
||||
public PlayerPerformanceService(ILogger<PlayerPerformanceService> logger, MareMediator mediator,
|
||||
ServerConfigurationManager serverConfigurationManager,
|
||||
PlayerPerformanceConfigService playerPerformanceConfigService, FileCacheManager fileCacheManager,
|
||||
XivDataAnalyzer xivDataAnalyzer)
|
||||
: base(logger, mediator)
|
||||
{
|
||||
_logger = logger;
|
||||
_mediator = mediator;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_playerPerformanceConfigService = playerPerformanceConfigService;
|
||||
_fileCacheManager = fileCacheManager;
|
||||
_xivDataAnalyzer = xivDataAnalyzer;
|
||||
}
|
||||
|
||||
public async Task<bool> CheckBothThresholds(PairHandler pairHandler, CharacterData charaData)
|
||||
{
|
||||
bool notPausedAfterVram = ComputeAndAutoPauseOnVRAMUsageThresholds(pairHandler, charaData, []);
|
||||
if (!notPausedAfterVram) return false;
|
||||
bool notPausedAfterTris = await CheckTriangleUsageThresholds(pairHandler, charaData).ConfigureAwait(false);
|
||||
if (!notPausedAfterTris) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> CheckTriangleUsageThresholds(PairHandler pairHandler, CharacterData charaData)
|
||||
{
|
||||
var config = _playerPerformanceConfigService.Current;
|
||||
var pair = pairHandler.Pair;
|
||||
|
||||
long triUsage = 0;
|
||||
|
||||
var moddedModelHashes = charaData.FileReplacements.SelectMany(k => k.Value)
|
||||
.Where(p => string.IsNullOrEmpty(p.FileSwapPath) && p.GamePaths.Any(g => g.EndsWith("mdl", StringComparison.OrdinalIgnoreCase)))
|
||||
.Select(p => p.Hash)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
foreach (var hash in moddedModelHashes)
|
||||
{
|
||||
triUsage += await Task.Run(() => _xivDataAnalyzer.GetTrianglesByHash(hash)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
pair.LastAppliedDataTris = triUsage;
|
||||
|
||||
_logger.LogDebug("Calculated Triangle usage for {p}", pairHandler);
|
||||
|
||||
long triUsageThreshold = config.TrisAutoPauseThresholdThousands * 1000;
|
||||
bool isDirect = pair.UserPair != null;
|
||||
bool autoPause = config.AutoPausePlayersExceedingThresholds;
|
||||
bool notify = isDirect ? config.NotifyAutoPauseDirectPairs : config.NotifyAutoPauseGroupPairs;
|
||||
|
||||
if (autoPause && isDirect && config.IgnoreDirectPairs)
|
||||
autoPause = false;
|
||||
|
||||
if (!autoPause || _serverConfigurationManager.IsUidWhitelisted(pair.UserData.UID))
|
||||
triUsageThreshold = MaxTriUsageThreshold;
|
||||
|
||||
if (triUsage > triUsageThreshold)
|
||||
{
|
||||
if (notify && !pair.IsApplicationBlocked)
|
||||
{
|
||||
_mediator.Publish(new NotificationMessage($"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically blocked",
|
||||
$"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured triangle auto block threshold (" +
|
||||
$"{triUsage}/{triUsageThreshold} triangles)" +
|
||||
$" and has been automatically blocked.",
|
||||
MareConfiguration.Models.NotificationType.Warning));
|
||||
}
|
||||
|
||||
_mediator.Publish(new EventMessage(new Event(pair.PlayerName, pair.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
|
||||
$"Exceeds triangle threshold: ({triUsage}/{triUsageThreshold} triangles)")));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ComputeAndAutoPauseOnVRAMUsageThresholds(PairHandler pairHandler, CharacterData charaData, List<DownloadFileTransfer> toDownloadFiles, bool affect = false)
|
||||
{
|
||||
var config = _playerPerformanceConfigService.Current;
|
||||
var pair = pairHandler.Pair;
|
||||
|
||||
long vramUsage = 0;
|
||||
|
||||
var moddedTextureHashes = charaData.FileReplacements.SelectMany(k => k.Value)
|
||||
.Where(p => string.IsNullOrEmpty(p.FileSwapPath) && p.GamePaths.Any(g => g.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)))
|
||||
.Select(p => p.Hash)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
foreach (var hash in moddedTextureHashes)
|
||||
{
|
||||
long fileSize = 0;
|
||||
|
||||
var download = toDownloadFiles.Find(f => string.Equals(hash, f.Hash, StringComparison.OrdinalIgnoreCase));
|
||||
if (download != null)
|
||||
{
|
||||
fileSize = download.TotalRaw;
|
||||
}
|
||||
else
|
||||
{
|
||||
var fileEntry = _fileCacheManager.GetFileCacheByHash(hash, preferSubst: true);
|
||||
if (fileEntry == null) continue;
|
||||
|
||||
if (fileEntry.Size == null)
|
||||
{
|
||||
fileEntry.Size = new FileInfo(fileEntry.ResolvedFilepath).Length;
|
||||
_fileCacheManager.UpdateHashedFile(fileEntry, computeProperties: true);
|
||||
}
|
||||
|
||||
fileSize = fileEntry.Size.Value;
|
||||
}
|
||||
|
||||
vramUsage += fileSize;
|
||||
}
|
||||
|
||||
pair.LastAppliedApproximateVRAMBytes = vramUsage;
|
||||
|
||||
_logger.LogDebug("Calculated VRAM usage for {p}", pairHandler);
|
||||
|
||||
long vramUsageThreshold = config.VRAMSizeAutoPauseThresholdMiB;
|
||||
bool isDirect = pair.UserPair != null;
|
||||
bool autoPause = config.AutoPausePlayersExceedingThresholds;
|
||||
bool notify = isDirect ? config.NotifyAutoPauseDirectPairs : config.NotifyAutoPauseGroupPairs;
|
||||
|
||||
if (autoPause && isDirect && config.IgnoreDirectPairs)
|
||||
autoPause = false;
|
||||
|
||||
if (!autoPause || _serverConfigurationManager.IsUidWhitelisted(pair.UserData.UID))
|
||||
vramUsageThreshold = MaxVRAMUsageThreshold;
|
||||
|
||||
if (vramUsage > vramUsageThreshold * 1024 * 1024)
|
||||
{
|
||||
if (!affect)
|
||||
return false;
|
||||
|
||||
if (notify && !pair.IsApplicationBlocked)
|
||||
{
|
||||
_mediator.Publish(new NotificationMessage($"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically blocked",
|
||||
$"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured VRAM auto block threshold (" +
|
||||
$"{UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{vramUsageThreshold}MiB)" +
|
||||
$" and has been automatically blocked.",
|
||||
MareConfiguration.Models.NotificationType.Warning));
|
||||
}
|
||||
|
||||
_mediator.Publish(new EventMessage(new Event(pair.PlayerName, pair.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
|
||||
$"Exceeds VRAM threshold: ({UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{vramUsageThreshold} MiB)")));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> ShrinkTextures(PairHandler pairHandler, CharacterData charaData, CancellationToken token)
|
||||
{
|
||||
var config = _playerPerformanceConfigService.Current;
|
||||
|
||||
if (config.TextureShrinkMode == MareConfiguration.Models.TextureShrinkMode.Never)
|
||||
return false;
|
||||
|
||||
// XXX: Temporary
|
||||
if (config.TextureShrinkMode == MareConfiguration.Models.TextureShrinkMode.Default)
|
||||
return false;
|
||||
|
||||
var moddedTextureHashes = charaData.FileReplacements.SelectMany(k => k.Value)
|
||||
.Where(p => string.IsNullOrEmpty(p.FileSwapPath) && p.GamePaths.Any(g => g.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)))
|
||||
.Select(p => p.Hash)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
bool shrunken = false;
|
||||
|
||||
await Parallel.ForEachAsync(moddedTextureHashes,
|
||||
token,
|
||||
async (hash, token) => {
|
||||
var fileEntry = _fileCacheManager.GetFileCacheByHash(hash, preferSubst: true);
|
||||
if (fileEntry == null) return;
|
||||
if (fileEntry.IsSubstEntry) return;
|
||||
|
||||
var texFormat = _xivDataAnalyzer.GetTexFormatByHash(hash);
|
||||
var filePath = fileEntry.ResolvedFilepath;
|
||||
var tmpFilePath = _fileCacheManager.GetSubstFilePath(Guid.NewGuid().ToString(), "tmp");
|
||||
var newFilePath = _fileCacheManager.GetSubstFilePath(hash, "tex");
|
||||
var mipLevel = 0;
|
||||
uint width = texFormat.Width;
|
||||
uint height = texFormat.Height;
|
||||
long offsetDelta = 0;
|
||||
|
||||
uint bitsPerPixel = texFormat.Format switch
|
||||
{
|
||||
0x1130 => 8, // L8
|
||||
0x1131 => 8, // A8
|
||||
0x1440 => 16, // A4R4G4B4
|
||||
0x1441 => 16, // A1R5G5B5
|
||||
0x1450 => 32, // A8R8G8B8
|
||||
0x1451 => 32, // X8R8G8B8
|
||||
0x2150 => 32, // R32F
|
||||
0x2250 => 32, // G16R16F
|
||||
0x2260 => 64, // R32G32F
|
||||
0x2460 => 64, // A16B16G16R16F
|
||||
0x2470 => 128, // A32B32G32R32F
|
||||
0x3420 => 4, // DXT1
|
||||
0x3430 => 8, // DXT3
|
||||
0x3431 => 8, // DXT5
|
||||
0x4140 => 16, // D16
|
||||
0x4250 => 32, // D24S8
|
||||
0x6120 => 4, // BC4
|
||||
0x6230 => 8, // BC5
|
||||
0x6432 => 8, // BC7
|
||||
_ => 0
|
||||
};
|
||||
|
||||
uint maxSize = (bitsPerPixel <= 8) ? (2048U * 2048U) : (1024U * 1024U);
|
||||
|
||||
while (width * height > maxSize && mipLevel < texFormat.MipCount - 1)
|
||||
{
|
||||
offsetDelta += width * height * bitsPerPixel / 8;
|
||||
mipLevel++;
|
||||
width /= 2;
|
||||
height /= 2;
|
||||
}
|
||||
|
||||
if (offsetDelta == 0)
|
||||
return;
|
||||
|
||||
_logger.LogDebug("Shrinking {hash} from from {a}x{b} to {c}x{d}",
|
||||
hash, texFormat.Width, texFormat.Height, width, height);
|
||||
|
||||
try
|
||||
{
|
||||
var inFile = new FileStream(filePath, FileMode.Open, FileAccess.Read);
|
||||
using var reader = new BinaryReader(inFile);
|
||||
|
||||
var header = reader.ReadBytes(80);
|
||||
reader.BaseStream.Position = 14;
|
||||
byte mipByte = reader.ReadByte();
|
||||
byte mipCount = (byte)(mipByte & 0x7F);
|
||||
|
||||
var outFile = new FileStream(tmpFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
using var writer = new BinaryWriter(outFile);
|
||||
writer.Write(header);
|
||||
|
||||
// Update width/height
|
||||
writer.BaseStream.Position = 8;
|
||||
writer.Write((ushort)width);
|
||||
writer.Write((ushort)height);
|
||||
|
||||
// Update the mip count
|
||||
writer.BaseStream.Position = 14;
|
||||
writer.Write((ushort)((mipByte & 0x80) | (mipCount - mipLevel)));
|
||||
|
||||
// Reset all of the LoD mips
|
||||
writer.BaseStream.Position = 16;
|
||||
for (int i = 0; i < 3; ++i)
|
||||
writer.Write((uint)0);
|
||||
|
||||
// Reset all of the mip offsets
|
||||
// (This data is garbage in a lot of modded textures, so its hard to fix it up correctly)
|
||||
writer.BaseStream.Position = 28;
|
||||
for (int i = 0; i < 13; ++i)
|
||||
writer.Write((uint)80);
|
||||
|
||||
// Write the texture data shifted
|
||||
outFile.Position = 80;
|
||||
inFile.Position = 80 + offsetDelta;
|
||||
|
||||
await inFile.CopyToAsync(outFile, 81920, token).ConfigureAwait(false);
|
||||
|
||||
reader.Dispose();
|
||||
writer.Dispose();
|
||||
|
||||
File.Move(tmpFilePath, newFilePath);
|
||||
var substEntry = _fileCacheManager.CreateSubstEntry(newFilePath);
|
||||
if (substEntry != null)
|
||||
substEntry.CompressedSize = fileEntry.CompressedSize;
|
||||
shrunken = true;
|
||||
|
||||
// Make sure its a cache file before trying to delete it !!
|
||||
bool shouldDelete = fileEntry.IsCacheEntry && File.Exists(filePath);
|
||||
|
||||
if (_playerPerformanceConfigService.Current.TextureShrinkDeleteOriginal && shouldDelete)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Deleting original texture: {filePath}", filePath);
|
||||
File.Delete(filePath);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogWarning(e, "Failed to shrink texture {hash}", hash);
|
||||
if (File.Exists(tmpFilePath))
|
||||
File.Delete(tmpFilePath);
|
||||
}
|
||||
}
|
||||
).ConfigureAwait(false);
|
||||
|
||||
return shrunken;
|
||||
}
|
||||
}
|
76
MareSynchronos/Services/PluginWarningNotificationService.cs
Normal file
76
MareSynchronos/Services/PluginWarningNotificationService.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using MareSynchronos.API.Data;
|
||||
using MareSynchronos.API.Data.Comparer;
|
||||
using MareSynchronos.Interop.Ipc;
|
||||
using MareSynchronos.MareConfiguration;
|
||||
using MareSynchronos.MareConfiguration.Models;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace MareSynchronos.PlayerData.Pairs;
|
||||
|
||||
public class PluginWarningNotificationService
|
||||
{
|
||||
private readonly ConcurrentDictionary<UserData, OptionalPluginWarning> _cachedOptionalPluginWarnings = new(UserDataComparer.Instance);
|
||||
private readonly IpcManager _ipcManager;
|
||||
private readonly MareConfigService _mareConfigService;
|
||||
private readonly MareMediator _mediator;
|
||||
|
||||
public PluginWarningNotificationService(MareConfigService mareConfigService, IpcManager ipcManager, MareMediator mediator)
|
||||
{
|
||||
_mareConfigService = mareConfigService;
|
||||
_ipcManager = ipcManager;
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
public void NotifyForMissingPlugins(UserData user, string playerName, HashSet<PlayerChanges> changes)
|
||||
{
|
||||
if (!_cachedOptionalPluginWarnings.TryGetValue(user, out var warning))
|
||||
{
|
||||
_cachedOptionalPluginWarnings[user] = warning = new()
|
||||
{
|
||||
ShownCustomizePlusWarning = _mareConfigService.Current.DisableOptionalPluginWarnings,
|
||||
ShownHeelsWarning = _mareConfigService.Current.DisableOptionalPluginWarnings,
|
||||
ShownHonorificWarning = _mareConfigService.Current.DisableOptionalPluginWarnings,
|
||||
ShowPetNicknamesWarning = _mareConfigService.Current.DisableOptionalPluginWarnings,
|
||||
ShownMoodlesWarning = _mareConfigService.Current.DisableOptionalPluginWarnings
|
||||
};
|
||||
}
|
||||
|
||||
List<string> missingPluginsForData = [];
|
||||
if (changes.Contains(PlayerChanges.Heels) && !warning.ShownHeelsWarning && !_ipcManager.Heels.APIAvailable)
|
||||
{
|
||||
missingPluginsForData.Add("SimpleHeels");
|
||||
warning.ShownHeelsWarning = true;
|
||||
}
|
||||
if (changes.Contains(PlayerChanges.Customize) && !warning.ShownCustomizePlusWarning && !_ipcManager.CustomizePlus.APIAvailable)
|
||||
{
|
||||
missingPluginsForData.Add("Customize+");
|
||||
warning.ShownCustomizePlusWarning = true;
|
||||
}
|
||||
|
||||
if (changes.Contains(PlayerChanges.Honorific) && !warning.ShownHonorificWarning && !_ipcManager.Honorific.APIAvailable)
|
||||
{
|
||||
missingPluginsForData.Add("Honorific");
|
||||
warning.ShownHonorificWarning = true;
|
||||
}
|
||||
|
||||
if (changes.Contains(PlayerChanges.PetNames) && !warning.ShowPetNicknamesWarning && !_ipcManager.PetNames.APIAvailable)
|
||||
{
|
||||
missingPluginsForData.Add("PetNicknames");
|
||||
warning.ShowPetNicknamesWarning = true;
|
||||
}
|
||||
|
||||
if (changes.Contains(PlayerChanges.Moodles) && !warning.ShownMoodlesWarning && !_ipcManager.Moodles.APIAvailable)
|
||||
{
|
||||
missingPluginsForData.Add("Moodles");
|
||||
warning.ShownMoodlesWarning = true;
|
||||
}
|
||||
|
||||
if (missingPluginsForData.Any())
|
||||
{
|
||||
_mediator.Publish(new NotificationMessage("Missing plugins for " + playerName,
|
||||
$"Received data for {playerName} that contained information for plugins you have not installed. Install {string.Join(", ", missingPluginsForData)} to experience their character fully.",
|
||||
NotificationType.Warning, TimeSpan.FromSeconds(10)));
|
||||
}
|
||||
}
|
||||
}
|
160
MareSynchronos/Services/PluginWatcherService.cs
Normal file
160
MareSynchronos/Services/PluginWatcherService.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
using Dalamud.Plugin;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using CapturedPluginState = (string InternalName, System.Version Version, bool IsLoaded);
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
/* Parts of this code from ECommons DalamudReflector
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 NightmareXIV
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
*/
|
||||
|
||||
public class PluginWatcherService : MediatorSubscriberBase, IHostedService
|
||||
{
|
||||
private readonly IDalamudPluginInterface _pluginInterface;
|
||||
|
||||
private CapturedPluginState[] _prevInstalledPluginState = [];
|
||||
|
||||
#pragma warning disable
|
||||
private static bool ExposedPluginsEqual(IEnumerable<IExposedPlugin> plugins, IEnumerable<CapturedPluginState> other)
|
||||
{
|
||||
if (plugins.Count() != other.Count()) return false;
|
||||
var enumeratorOriginal = plugins.GetEnumerator();
|
||||
var enumeratorOther = other.GetEnumerator();
|
||||
while (true)
|
||||
{
|
||||
var move1 = enumeratorOriginal.MoveNext();
|
||||
var move2 = enumeratorOther.MoveNext();
|
||||
if (move1 != move2) return false;
|
||||
if (move1 == false) return true;
|
||||
if (enumeratorOriginal.Current.IsLoaded != enumeratorOther.Current.IsLoaded) return false;
|
||||
if (enumeratorOriginal.Current.Version != enumeratorOther.Current.Version) return false;
|
||||
if (enumeratorOriginal.Current.InternalName != enumeratorOther.Current.InternalName) return false;
|
||||
}
|
||||
}
|
||||
#pragma warning restore
|
||||
|
||||
public PluginWatcherService(ILogger<PluginWatcherService> logger, IDalamudPluginInterface pluginInterface, MareMediator mediator) : base(logger, mediator)
|
||||
{
|
||||
_pluginInterface = pluginInterface;
|
||||
|
||||
Mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, (_) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Update();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.LogError(e, "PluginWatcherService exception");
|
||||
}
|
||||
});
|
||||
|
||||
// Continue scanning plugins during gpose as well
|
||||
Mediator.Subscribe<CutsceneFrameworkUpdateMessage>(this, (_) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Update();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.LogError(e, "PluginWatcherService exception");
|
||||
}
|
||||
});
|
||||
|
||||
Update(publish: false);
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Mediator.UnsubscribeAll(this);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public static PluginChangeMessage? GetInitialPluginState(IDalamudPluginInterface pi, string internalName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var plugin = pi.InstalledPlugins.Where(p => p.InternalName.Equals(internalName, StringComparison.Ordinal))
|
||||
.OrderBy(p => (!p.IsLoaded, p.Version))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (plugin == null)
|
||||
return null;
|
||||
|
||||
return new PluginChangeMessage(plugin.InternalName, plugin.Version, plugin.IsLoaded);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update(bool publish = true)
|
||||
{
|
||||
if (!ExposedPluginsEqual(_pluginInterface.InstalledPlugins, _prevInstalledPluginState))
|
||||
{
|
||||
var state = _pluginInterface.InstalledPlugins.Select(x => new CapturedPluginState(x.InternalName, x.Version, x.IsLoaded)).ToArray();
|
||||
|
||||
// The same plugin can be installed multiple times -- InternalName is not unique
|
||||
|
||||
var oldDict = _prevInstalledPluginState.Where(x => x.InternalName.Length > 0)
|
||||
.GroupBy(x => x.InternalName, StringComparer.Ordinal)
|
||||
.ToDictionary(x => x.Key, StringComparer.Ordinal);
|
||||
|
||||
var newDict = state.Where(x => x.InternalName.Length > 0)
|
||||
.GroupBy(x => x.InternalName, StringComparer.Ordinal)
|
||||
.ToDictionary(x => x.Key, StringComparer.Ordinal);
|
||||
|
||||
_prevInstalledPluginState = state;
|
||||
|
||||
foreach (var internalName in newDict.Keys.Except(oldDict.Keys, StringComparer.Ordinal))
|
||||
{
|
||||
var p = newDict[internalName].OrderBy(p => (!p.IsLoaded, p.Version)).First();
|
||||
if (publish) Mediator.Publish(new PluginChangeMessage(internalName, p.Version, p.IsLoaded));
|
||||
}
|
||||
|
||||
foreach (var internalName in oldDict.Keys.Except(newDict.Keys, StringComparer.Ordinal))
|
||||
{
|
||||
var p = oldDict[internalName].OrderBy(p => (!p.IsLoaded, p.Version)).First();
|
||||
if (publish) Mediator.Publish(new PluginChangeMessage(p.InternalName, p.Version, IsLoaded: false));
|
||||
}
|
||||
|
||||
foreach (var changedGroup in newDict.Where(p => oldDict.TryGetValue(p.Key, out var old) && !old.SequenceEqual(p.Value)))
|
||||
{
|
||||
var internalName = changedGroup.Value.First().InternalName;
|
||||
var p = newDict[internalName].OrderBy(p => (!p.IsLoaded, p.Version)).First();
|
||||
if (publish) Mediator.Publish(new PluginChangeMessage(p.InternalName, p.Version, p.IsLoaded));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
202
MareSynchronos/Services/RemoteConfigurationService.cs
Normal file
202
MareSynchronos/Services/RemoteConfigurationService.cs
Normal file
@@ -0,0 +1,202 @@
|
||||
using Chaos.NaCl;
|
||||
using MareSynchronos.MareConfiguration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
public sealed class RemoteConfigurationService
|
||||
{
|
||||
// private readonly static Dictionary<string, string> ConfigPublicKeys = new(StringComparer.Ordinal)
|
||||
// {
|
||||
// { "", "" },
|
||||
// };
|
||||
|
||||
private readonly static string[] ConfigSources = [
|
||||
"https://snowcloak-sync.com/config.json",
|
||||
];
|
||||
|
||||
private readonly ILogger<RemoteConfigurationService> _logger;
|
||||
private readonly RemoteConfigCacheService _configService;
|
||||
private readonly Task _initTask;
|
||||
|
||||
public RemoteConfigurationService(ILogger<RemoteConfigurationService> logger, RemoteConfigCacheService configService)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_initTask = Task.Run(DownloadConfig);
|
||||
}
|
||||
|
||||
public async Task<JsonObject> GetConfigAsync(string sectionName)
|
||||
{
|
||||
await _initTask.ConfigureAwait(false);
|
||||
if (!_configService.Current.Configuration.TryGetPropertyValue(sectionName, out var section))
|
||||
section = null;
|
||||
return (section as JsonObject) ?? new();
|
||||
}
|
||||
|
||||
public async Task<T?> GetConfigAsync<T>(string sectionName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await GetConfigAsync(sectionName).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<T>(json);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid JSON in remote config: {sectionName}", sectionName);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadConfig()
|
||||
{
|
||||
string? jsonResponse = null;
|
||||
|
||||
foreach (var remoteUrl in ConfigSources)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Fetching {url}", remoteUrl);
|
||||
|
||||
using var httpClient = new HttpClient(
|
||||
new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = true,
|
||||
MaxAutomaticRedirections = 5
|
||||
}
|
||||
);
|
||||
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(6);
|
||||
|
||||
var ver = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("MareSynchronos", ver!.Major + "." + ver!.Minor + "." + ver!.Build));
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, remoteUrl);
|
||||
|
||||
if (remoteUrl.Equals(_configService.Current.Origin, StringComparison.Ordinal))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_configService.Current.ETag))
|
||||
request.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(_configService.Current.ETag));
|
||||
|
||||
if (_configService.Current.LastModified != null)
|
||||
request.Headers.IfModifiedSince = _configService.Current.LastModified;
|
||||
}
|
||||
|
||||
var response = await httpClient.SendAsync(request).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotModified)
|
||||
{
|
||||
_logger.LogDebug("Using cached remote configuration from {url}", remoteUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var contentType = response.Content.Headers.ContentType?.MediaType;
|
||||
|
||||
if (contentType == null || !contentType.Equals("application/json", StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning("HTTP request for remote config failed: wrong MIME type");
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Downloaded new configuration from {url}", remoteUrl);
|
||||
|
||||
_configService.Current.Origin = remoteUrl;
|
||||
_configService.Current.ETag = response.Headers.ETag?.ToString() ?? string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
if (response.Content.Headers.Contains("Last-Modified"))
|
||||
{
|
||||
var lastModified = response.Content.Headers.GetValues("Last-Modified").First();
|
||||
_configService.Current.LastModified = DateTimeOffset.Parse(lastModified, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
_configService.Current.LastModified = null;
|
||||
}
|
||||
|
||||
jsonResponse = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "HTTP request for remote config failed");
|
||||
|
||||
if (remoteUrl.Equals(_configService.Current.Origin, StringComparison.Ordinal))
|
||||
{
|
||||
_configService.Current.ETag = string.Empty;
|
||||
_configService.Current.LastModified = null;
|
||||
_configService.Save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonResponse == null)
|
||||
{
|
||||
_logger.LogWarning("Could not download remote config");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var jsonDoc = JsonNode.Parse(jsonResponse) as JsonObject;
|
||||
|
||||
if (jsonDoc == null)
|
||||
{
|
||||
_logger.LogWarning("Downloaded remote config is not a JSON object");
|
||||
return;
|
||||
}
|
||||
|
||||
LoadConfig(jsonDoc);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid JSON in remote config response");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool VerifySignature(string message, ulong ts, string signature, string pubKey)
|
||||
{
|
||||
byte[] msg = [.. BitConverter.GetBytes(ts), .. Encoding.UTF8.GetBytes(message)];
|
||||
byte[] sig = Convert.FromBase64String(signature);
|
||||
byte[] pub = Convert.FromBase64String(pubKey);
|
||||
return Ed25519.Verify(sig, msg, pub);
|
||||
}
|
||||
|
||||
private void LoadConfig(JsonObject jsonDoc)
|
||||
{
|
||||
var ts = jsonDoc["ts"]!.GetValue<ulong>();
|
||||
|
||||
if (ts <= _configService.Current.Timestamp)
|
||||
{
|
||||
_logger.LogDebug("Remote configuration is not newer than cached config");
|
||||
return;
|
||||
}
|
||||
|
||||
var signatures = jsonDoc["sig"]!.AsObject();
|
||||
var configString = jsonDoc["config"]!.GetValue<string>();
|
||||
// bool verified = signatures.Any(sig =>
|
||||
// ConfigPublicKeys.TryGetValue(sig.Key, out var pubKey) &&
|
||||
// VerifySignature(configString, ts, sig.Value!.GetValue<string>(), pubKey));
|
||||
|
||||
bool verified = true;
|
||||
if (!verified)
|
||||
{
|
||||
_logger.LogWarning("Could not verify signature for downloaded remote config");
|
||||
return;
|
||||
}
|
||||
|
||||
_configService.Current.Configuration = JsonNode.Parse(configString)!.AsObject();
|
||||
_configService.Current.Timestamp = ts;
|
||||
_configService.Save();
|
||||
}
|
||||
}
|
12
MareSynchronos/Services/RepoChangeConfig.cs
Normal file
12
MareSynchronos/Services/RepoChangeConfig.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
public record RepoChangeConfig
|
||||
{
|
||||
[JsonPropertyName("current_repo")]
|
||||
public string? CurrentRepo { get; set; }
|
||||
|
||||
[JsonPropertyName("valid_repos")]
|
||||
public string[]? ValidRepos { get; set; }
|
||||
}
|
401
MareSynchronos/Services/RepoChangeService.cs
Normal file
401
MareSynchronos/Services/RepoChangeService.cs
Normal file
@@ -0,0 +1,401 @@
|
||||
using Dalamud.Plugin;
|
||||
using Dalamud.Plugin.Services;
|
||||
using Dalamud.Utility;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Reflection;
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
/* Reflection code based almost entirely on ECommons DalamudReflector
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 NightmareXIV
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
*/
|
||||
|
||||
public sealed class RepoChangeService : IHostedService
|
||||
{
|
||||
#region Reflection Helpers
|
||||
private const BindingFlags AllFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
|
||||
private const BindingFlags StaticFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static;
|
||||
private const BindingFlags InstanceFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
private static object GetFoP(object obj, string name)
|
||||
{
|
||||
Type? type = obj.GetType();
|
||||
while (type != null)
|
||||
{
|
||||
var fieldInfo = type.GetField(name, AllFlags);
|
||||
if (fieldInfo != null)
|
||||
{
|
||||
return fieldInfo.GetValue(obj)!;
|
||||
}
|
||||
var propertyInfo = type.GetProperty(name, AllFlags);
|
||||
if (propertyInfo != null)
|
||||
{
|
||||
return propertyInfo.GetValue(obj)!;
|
||||
}
|
||||
type = type.BaseType;
|
||||
}
|
||||
throw new Exception($"Reflection GetFoP failed (not found: {obj.GetType().Name}.{name})");
|
||||
}
|
||||
|
||||
private static T GetFoP<T>(object obj, string name)
|
||||
{
|
||||
return (T)GetFoP(obj, name);
|
||||
}
|
||||
|
||||
private static void SetFoP(object obj, string name, object value)
|
||||
{
|
||||
var type = obj.GetType();
|
||||
var field = type.GetField(name, AllFlags);
|
||||
if (field != null)
|
||||
{
|
||||
field.SetValue(obj, value);
|
||||
}
|
||||
else
|
||||
{
|
||||
var prop = type.GetProperty(name, AllFlags)!;
|
||||
if (prop == null)
|
||||
throw new Exception($"Reflection SetFoP failed (not found: {type.Name}.{name})");
|
||||
prop.SetValue(obj, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static object? Call(object obj, string name, object[] @params, bool matchExactArgumentTypes = false)
|
||||
{
|
||||
MethodInfo? info;
|
||||
var type = obj.GetType();
|
||||
if (!matchExactArgumentTypes)
|
||||
{
|
||||
info = type.GetMethod(name, AllFlags);
|
||||
}
|
||||
else
|
||||
{
|
||||
info = type.GetMethod(name, AllFlags, @params.Select(x => x.GetType()).ToArray());
|
||||
}
|
||||
if (info == null)
|
||||
throw new Exception($"Reflection Call failed (not found: {type.Name}.{name})");
|
||||
return info.Invoke(obj, @params);
|
||||
}
|
||||
|
||||
private static T Call<T>(object obj, string name, object[] @params, bool matchExactArgumentTypes = false)
|
||||
{
|
||||
return (T)Call(obj, name, @params, matchExactArgumentTypes)!;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Dalamud Reflection
|
||||
public object GetService(string serviceFullName)
|
||||
{
|
||||
return _pluginInterface.GetType().Assembly.
|
||||
GetType("Dalamud.Service`1", true)!.MakeGenericType(_pluginInterface.GetType().Assembly.GetType(serviceFullName, true)!).
|
||||
GetMethod("Get")!.Invoke(null, BindingFlags.Default, null, Array.Empty<object>(), null)!;
|
||||
}
|
||||
|
||||
private object GetPluginManager()
|
||||
{
|
||||
return _pluginInterface.GetType().Assembly.
|
||||
GetType("Dalamud.Service`1", true)!.MakeGenericType(_pluginInterface.GetType().Assembly.GetType("Dalamud.Plugin.Internal.PluginManager", true)!).
|
||||
GetMethod("Get")!.Invoke(null, BindingFlags.Default, null, Array.Empty<object>(), null)!;
|
||||
}
|
||||
|
||||
private void ReloadPluginMasters()
|
||||
{
|
||||
var mgr = GetService("Dalamud.Plugin.Internal.PluginManager");
|
||||
var pluginReload = mgr.GetType().GetMethod("SetPluginReposFromConfigAsync", BindingFlags.Instance | BindingFlags.Public)!;
|
||||
pluginReload.Invoke(mgr, [true]);
|
||||
}
|
||||
|
||||
public void SaveDalamudConfig()
|
||||
{
|
||||
var conf = GetService("Dalamud.Configuration.Internal.DalamudConfiguration");
|
||||
var configSave = conf?.GetType().GetMethod("QueueSave", BindingFlags.Instance | BindingFlags.Public);
|
||||
configSave?.Invoke(conf, null);
|
||||
}
|
||||
|
||||
private IEnumerable<object> GetRepoByURL(string repoURL)
|
||||
{
|
||||
var conf = GetService("Dalamud.Configuration.Internal.DalamudConfiguration");
|
||||
var repolist = (System.Collections.IEnumerable)GetFoP(conf, "ThirdRepoList");
|
||||
foreach (var r in repolist)
|
||||
{
|
||||
if (((string)GetFoP(r, "Url")).Equals(repoURL, StringComparison.OrdinalIgnoreCase))
|
||||
yield return r;
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasRepo(string repoURL)
|
||||
{
|
||||
var conf = GetService("Dalamud.Configuration.Internal.DalamudConfiguration");
|
||||
var repolist = (System.Collections.IEnumerable)GetFoP(conf, "ThirdRepoList");
|
||||
foreach (var r in repolist)
|
||||
{
|
||||
if (((string)GetFoP(r, "Url")).Equals(repoURL, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void AddRepo(string repoURL, bool enabled)
|
||||
{
|
||||
var conf = GetService("Dalamud.Configuration.Internal.DalamudConfiguration");
|
||||
var repolist = (System.Collections.IEnumerable)GetFoP(conf, "ThirdRepoList");
|
||||
foreach (var r in repolist)
|
||||
{
|
||||
if (((string)GetFoP(r, "Url")).Equals(repoURL, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
}
|
||||
var instance = Activator.CreateInstance(_pluginInterface.GetType().Assembly.GetType("Dalamud.Configuration.ThirdPartyRepoSettings")!)!;
|
||||
SetFoP(instance, "Url", repoURL);
|
||||
SetFoP(instance, "IsEnabled", enabled);
|
||||
GetFoP<System.Collections.IList>(conf, "ThirdRepoList").Add(instance!);
|
||||
}
|
||||
|
||||
private void RemoveRepo(string repoURL)
|
||||
{
|
||||
var toRemove = new List<object>();
|
||||
var conf = GetService("Dalamud.Configuration.Internal.DalamudConfiguration");
|
||||
var repolist = (System.Collections.IList)GetFoP(conf, "ThirdRepoList");
|
||||
foreach (var r in repolist)
|
||||
{
|
||||
if (((string)GetFoP(r, "Url")).Equals(repoURL, StringComparison.OrdinalIgnoreCase))
|
||||
toRemove.Add(r);
|
||||
}
|
||||
foreach (var r in toRemove)
|
||||
repolist.Remove(r);
|
||||
}
|
||||
|
||||
public List<(object LocalPlugin, string InstalledFromUrl)> GetLocalPluginsByName(string internalName)
|
||||
{
|
||||
List<(object LocalPlugin, string RepoURL)> result = [];
|
||||
|
||||
var pluginManager = GetPluginManager();
|
||||
var installedPlugins = (System.Collections.IList)pluginManager.GetType().GetProperty("InstalledPlugins")!.GetValue(pluginManager)!;
|
||||
|
||||
foreach (var plugin in installedPlugins)
|
||||
{
|
||||
if (((string)plugin.GetType().GetProperty("InternalName")!.GetValue(plugin)!).Equals(internalName, StringComparison.Ordinal))
|
||||
{
|
||||
var type = plugin.GetType();
|
||||
if (type.Name.Equals("LocalDevPlugin", StringComparison.Ordinal))
|
||||
continue;
|
||||
var manifest = GetFoP(plugin, "manifest");
|
||||
string installedFromUrl = (string)GetFoP(manifest, "InstalledFromUrl");
|
||||
result.Add((plugin, installedFromUrl));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
#endregion
|
||||
|
||||
private readonly ILogger<RepoChangeService> _logger;
|
||||
private readonly RemoteConfigurationService _remoteConfig;
|
||||
private readonly IDalamudPluginInterface _pluginInterface;
|
||||
private readonly IFramework _framework;
|
||||
|
||||
public RepoChangeService(ILogger<RepoChangeService> logger, RemoteConfigurationService remoteConfig, IDalamudPluginInterface pluginInterface, IFramework framework)
|
||||
{
|
||||
_logger = logger;
|
||||
_remoteConfig = remoteConfig;
|
||||
_pluginInterface = pluginInterface;
|
||||
_framework = framework;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Starting RepoChange Service");
|
||||
var repoChangeConfig = await _remoteConfig.GetConfigAsync<RepoChangeConfig>("repoChange").ConfigureAwait(false) ?? new();
|
||||
|
||||
var currentRepo = repoChangeConfig.CurrentRepo;
|
||||
var validRepos = (repoChangeConfig.ValidRepos ?? []).ToList();
|
||||
|
||||
if (!currentRepo.IsNullOrEmpty() && !validRepos.Contains(currentRepo, StringComparer.Ordinal))
|
||||
validRepos.Add(currentRepo);
|
||||
|
||||
if (validRepos.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No valid repos configured, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
await _framework.RunOnTick(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var internalName = Assembly.GetExecutingAssembly().GetName().Name!;
|
||||
var localPlugins = GetLocalPluginsByName(internalName);
|
||||
|
||||
var suffix = string.Empty;
|
||||
|
||||
if (localPlugins.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("Skipping: No intalled plugin found");
|
||||
return;
|
||||
}
|
||||
|
||||
var hasValidCustomRepoUrl = false;
|
||||
|
||||
foreach (var vr in validRepos)
|
||||
{
|
||||
var vrCN = vr.Replace(".json", "_CN.json", StringComparison.Ordinal);
|
||||
var vrKR = vr.Replace(".json", "_KR.json", StringComparison.Ordinal);
|
||||
if (HasRepo(vr) || HasRepo(vrCN) || HasRepo(vrKR))
|
||||
{
|
||||
hasValidCustomRepoUrl = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
List<string> oldRepos = [];
|
||||
var pluginRepoUrl = localPlugins[0].InstalledFromUrl;
|
||||
|
||||
if (pluginRepoUrl.Contains("_CN.json", StringComparison.Ordinal))
|
||||
suffix = "_CN";
|
||||
else if (pluginRepoUrl.Contains("_KR.json", StringComparison.Ordinal))
|
||||
suffix = "_KR";
|
||||
|
||||
bool hasOldPluginRepoUrl = false;
|
||||
|
||||
foreach (var plugin in localPlugins)
|
||||
{
|
||||
foreach (var vr in validRepos)
|
||||
{
|
||||
var validRepo = vr.Replace(".json", $"{suffix}.json");
|
||||
if (!plugin.InstalledFromUrl.Equals(validRepo, StringComparison.Ordinal))
|
||||
{
|
||||
oldRepos.Add(plugin.InstalledFromUrl);
|
||||
hasOldPluginRepoUrl = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasValidCustomRepoUrl)
|
||||
{
|
||||
if (hasOldPluginRepoUrl)
|
||||
_logger.LogInformation("Result: Repo URL is up to date, but plugin install source is incorrect");
|
||||
else
|
||||
_logger.LogInformation("Result: Repo URL is up to date");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Result: Repo URL needs to be replaced");
|
||||
}
|
||||
|
||||
if (currentRepo.IsNullOrEmpty())
|
||||
{
|
||||
_logger.LogWarning("No current repo URL configured");
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-test plugin repo url rewriting to ensure it succeeds before replacing the custom repo URL
|
||||
if (hasOldPluginRepoUrl)
|
||||
{
|
||||
foreach (var plugin in localPlugins)
|
||||
{
|
||||
var manifest = GetFoP(plugin.LocalPlugin, "manifest");
|
||||
if (manifest == null)
|
||||
throw new Exception("Plugin manifest is null");
|
||||
var manifestFile = GetFoP(plugin.LocalPlugin, "manifestFile");
|
||||
if (manifestFile == null)
|
||||
throw new Exception("Plugin manifestFile is null");
|
||||
var repo = GetFoP(manifest, "InstalledFromUrl");
|
||||
if (((string)repo).IsNullOrEmpty())
|
||||
throw new Exception("Plugin repo url is null or empty");
|
||||
SetFoP(manifest, "InstalledFromUrl", repo);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasValidCustomRepoUrl)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var oldRepo in oldRepos)
|
||||
{
|
||||
_logger.LogInformation("* Removing old repo: {r}", oldRepo);
|
||||
RemoveRepo(oldRepo);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_logger.LogInformation("* Adding current repo: {r}", currentRepo);
|
||||
AddRepo(currentRepo, true);
|
||||
}
|
||||
}
|
||||
|
||||
// This time do it for real, and crash the game if we fail, to avoid saving a broken state
|
||||
if (hasOldPluginRepoUrl)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("* Updating plugins");
|
||||
foreach (var plugin in localPlugins)
|
||||
{
|
||||
var manifest = GetFoP(plugin.LocalPlugin, "manifest");
|
||||
if (manifest == null)
|
||||
throw new Exception("Plugin manifest is null");
|
||||
var manifestFile = GetFoP(plugin.LocalPlugin, "manifestFile");
|
||||
if (manifestFile == null)
|
||||
throw new Exception("Plugin manifestFile is null");
|
||||
var repo = GetFoP(manifest, "InstalledFromUrl");
|
||||
if (((string)repo).IsNullOrEmpty())
|
||||
throw new Exception("Plugin repo url is null or empty");
|
||||
SetFoP(manifest, "InstalledFromUrl", currentRepo);
|
||||
Call(manifest, "Save", [manifestFile, "RepoChange"]);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception while changing plugin install repo");
|
||||
foreach (var oldRepo in oldRepos)
|
||||
{
|
||||
_logger.LogInformation("* Restoring old repo: {r}", oldRepo);
|
||||
AddRepo(oldRepo, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasValidCustomRepoUrl || hasOldPluginRepoUrl)
|
||||
{
|
||||
_logger.LogInformation("* Saving dalamud config");
|
||||
SaveDalamudConfig();
|
||||
_logger.LogInformation("* Reloading plugin masters");
|
||||
ReloadPluginMasters();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception in RepoChangeService");
|
||||
}
|
||||
}, default, 10, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Started RepoChangeService");
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_ = cancellationToken;
|
||||
_logger.LogDebug("Stopping RepoChange Service");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
@@ -0,0 +1,547 @@
|
||||
using MareSynchronos.MareConfiguration;
|
||||
using MareSynchronos.MareConfiguration.Models;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using MareSynchronos.Utils;
|
||||
using MareSynchronos.WebAPI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace MareSynchronos.Services.ServerConfiguration;
|
||||
|
||||
public class ServerConfigurationManager
|
||||
{
|
||||
private readonly ServerConfigService _configService;
|
||||
private readonly DalamudUtilService _dalamudUtil;
|
||||
private readonly ILogger<ServerConfigurationManager> _logger;
|
||||
private readonly NotesConfigService _notesConfig;
|
||||
private readonly ServerBlockConfigService _blockConfig;
|
||||
private readonly ServerTagConfigService _serverTagConfig;
|
||||
private readonly SyncshellConfigService _syncshellConfig;
|
||||
|
||||
private HashSet<string>? _cachedWhitelistedUIDs = null;
|
||||
private HashSet<string>? _cachedBlacklistedUIDs = null;
|
||||
private string? _realApiUrl = null;
|
||||
|
||||
public ServerConfigurationManager(ILogger<ServerConfigurationManager> logger, ServerConfigService configService,
|
||||
ServerTagConfigService serverTagConfig, SyncshellConfigService syncshellConfig, NotesConfigService notesConfig,
|
||||
ServerBlockConfigService blockConfig, DalamudUtilService dalamudUtil)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_serverTagConfig = serverTagConfig;
|
||||
_syncshellConfig = syncshellConfig;
|
||||
_notesConfig = notesConfig;
|
||||
_blockConfig = blockConfig;
|
||||
_dalamudUtil = dalamudUtil;
|
||||
EnsureMainExists();
|
||||
}
|
||||
|
||||
public string CurrentApiUrl => CurrentServer.ServerUri;
|
||||
public string CurrentRealApiUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
return _realApiUrl ?? CurrentApiUrl;
|
||||
}
|
||||
}
|
||||
public ServerStorage CurrentServer => _configService.Current.ServerStorage[CurrentServerIndex];
|
||||
|
||||
public IReadOnlyList<string> Whitelist => CurrentBlockStorage().Whitelist;
|
||||
public IReadOnlyList<string> Blacklist => CurrentBlockStorage().Blacklist;
|
||||
|
||||
public int CurrentServerIndex
|
||||
{
|
||||
set
|
||||
{
|
||||
_configService.Current.CurrentServer = value;
|
||||
_cachedWhitelistedUIDs = null;
|
||||
_cachedBlacklistedUIDs = null;
|
||||
_realApiUrl = null;
|
||||
_configService.Save();
|
||||
}
|
||||
get
|
||||
{
|
||||
if (_configService.Current.CurrentServer < 0)
|
||||
{
|
||||
_configService.Current.CurrentServer = 0;
|
||||
_configService.Save();
|
||||
}
|
||||
|
||||
return _configService.Current.CurrentServer;
|
||||
}
|
||||
}
|
||||
|
||||
public string? GetSecretKey(out bool hasMulti, int serverIdx = -1)
|
||||
{
|
||||
ServerStorage? currentServer;
|
||||
currentServer = serverIdx == -1 ? CurrentServer : GetServerByIndex(serverIdx);
|
||||
if (currentServer == null)
|
||||
{
|
||||
currentServer = new();
|
||||
Save();
|
||||
}
|
||||
hasMulti = false;
|
||||
|
||||
var charaName = _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult();
|
||||
var worldId = _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult();
|
||||
if (!currentServer.Authentications.Any() && currentServer.SecretKeys.Any())
|
||||
{
|
||||
currentServer.Authentications.Add(new Authentication()
|
||||
{
|
||||
CharacterName = charaName,
|
||||
WorldId = worldId,
|
||||
SecretKeyIdx = currentServer.SecretKeys.Last().Key,
|
||||
});
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
var auth = currentServer.Authentications.FindAll(f => string.Equals(f.CharacterName, charaName, StringComparison.Ordinal) && f.WorldId == worldId);
|
||||
if (auth.Count >= 2)
|
||||
{
|
||||
_logger.LogTrace("GetSecretKey accessed, returning null because multiple ({count}) identical characters.", auth.Count);
|
||||
hasMulti = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (auth.Count == 0)
|
||||
{
|
||||
_logger.LogTrace("GetSecretKey accessed, returning null because no set up characters for {chara} on {world}", charaName, worldId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentServer.SecretKeys.TryGetValue(auth.Single().SecretKeyIdx, out var secretKey))
|
||||
{
|
||||
_logger.LogTrace("GetSecretKey accessed, returning {key} ({keyValue}) for {chara} on {world}", secretKey.FriendlyName, string.Join("", secretKey.Key.Take(10)), charaName, worldId);
|
||||
return secretKey.Key;
|
||||
}
|
||||
|
||||
_logger.LogTrace("GetSecretKey accessed, returning null because no fitting key found for {chara} on {world} for idx {idx}.", charaName, worldId, auth.Single().SecretKeyIdx);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public string[] GetServerApiUrls()
|
||||
{
|
||||
return _configService.Current.ServerStorage.Select(v => v.ServerUri).ToArray();
|
||||
}
|
||||
|
||||
public ServerStorage GetServerByIndex(int idx)
|
||||
{
|
||||
try
|
||||
{
|
||||
return _configService.Current.ServerStorage[idx];
|
||||
}
|
||||
catch
|
||||
{
|
||||
_configService.Current.CurrentServer = 0;
|
||||
EnsureMainExists();
|
||||
return CurrentServer!;
|
||||
}
|
||||
}
|
||||
|
||||
public string[] GetServerNames()
|
||||
{
|
||||
return _configService.Current.ServerStorage.Select(v => v.ServerName).ToArray();
|
||||
}
|
||||
|
||||
public bool HasValidConfig()
|
||||
{
|
||||
return CurrentServer != null && CurrentServer.SecretKeys.Any();
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
var caller = new StackTrace().GetFrame(1)?.GetMethod()?.ReflectedType?.Name ?? "Unknown";
|
||||
_logger.LogDebug("{caller} Calling config save", caller);
|
||||
_configService.Save();
|
||||
}
|
||||
|
||||
public void SelectServer(int idx)
|
||||
{
|
||||
_configService.Current.CurrentServer = idx;
|
||||
CurrentServer!.FullPause = false;
|
||||
Save();
|
||||
}
|
||||
|
||||
internal void AddCurrentCharacterToServer(int serverSelectionIndex = -1, bool save = true)
|
||||
{
|
||||
if (serverSelectionIndex == -1) serverSelectionIndex = CurrentServerIndex;
|
||||
var server = GetServerByIndex(serverSelectionIndex);
|
||||
if (server.Authentications.Any(c => string.Equals(c.CharacterName, _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult(), StringComparison.Ordinal)
|
||||
&& c.WorldId == _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult()))
|
||||
return;
|
||||
|
||||
server.Authentications.Add(new Authentication()
|
||||
{
|
||||
CharacterName = _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult(),
|
||||
WorldId = _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult(),
|
||||
SecretKeyIdx = server.SecretKeys.Last().Key,
|
||||
});
|
||||
|
||||
if (save)
|
||||
Save();
|
||||
}
|
||||
|
||||
internal void AddEmptyCharacterToServer(int serverSelectionIndex)
|
||||
{
|
||||
var server = GetServerByIndex(serverSelectionIndex);
|
||||
server.Authentications.Add(new Authentication()
|
||||
{
|
||||
SecretKeyIdx = server.SecretKeys.Any() ? server.SecretKeys.First().Key : -1,
|
||||
});
|
||||
Save();
|
||||
}
|
||||
|
||||
internal void AddOpenPairTag(string tag)
|
||||
{
|
||||
CurrentServerTagStorage().OpenPairTags.Add(tag);
|
||||
_serverTagConfig.Save();
|
||||
}
|
||||
|
||||
internal void AddServer(ServerStorage serverStorage)
|
||||
{
|
||||
_configService.Current.ServerStorage.Add(serverStorage);
|
||||
Save();
|
||||
}
|
||||
|
||||
internal void AddTag(string tag)
|
||||
{
|
||||
CurrentServerTagStorage().ServerAvailablePairTags.Add(tag);
|
||||
_serverTagConfig.Save();
|
||||
}
|
||||
|
||||
internal void AddTagForUid(string uid, string tagName)
|
||||
{
|
||||
if (CurrentServerTagStorage().UidServerPairedUserTags.TryGetValue(uid, out var tags))
|
||||
{
|
||||
tags.Add(tagName);
|
||||
}
|
||||
else
|
||||
{
|
||||
CurrentServerTagStorage().UidServerPairedUserTags[uid] = [tagName];
|
||||
}
|
||||
|
||||
_serverTagConfig.Save();
|
||||
}
|
||||
|
||||
internal bool ContainsOpenPairTag(string tag)
|
||||
{
|
||||
return CurrentServerTagStorage().OpenPairTags.Contains(tag);
|
||||
}
|
||||
|
||||
internal bool ContainsTag(string uid, string tag)
|
||||
{
|
||||
if (CurrentServerTagStorage().UidServerPairedUserTags.TryGetValue(uid, out var tags))
|
||||
{
|
||||
return tags.Contains(tag, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal void DeleteServer(ServerStorage selectedServer)
|
||||
{
|
||||
if (Array.IndexOf(_configService.Current.ServerStorage.ToArray(), selectedServer) <
|
||||
_configService.Current.CurrentServer)
|
||||
{
|
||||
_configService.Current.CurrentServer--;
|
||||
}
|
||||
|
||||
_configService.Current.ServerStorage.Remove(selectedServer);
|
||||
Save();
|
||||
}
|
||||
|
||||
internal string? GetNoteForGid(string gID)
|
||||
{
|
||||
if (CurrentNotesStorage().GidServerComments.TryGetValue(gID, out var note))
|
||||
{
|
||||
if (string.IsNullOrEmpty(note)) return null;
|
||||
return note;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
internal string? GetNoteForUid(string uid)
|
||||
{
|
||||
if (CurrentNotesStorage().UidServerComments.TryGetValue(uid, out var note))
|
||||
{
|
||||
if (string.IsNullOrEmpty(note)) return null;
|
||||
return note;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
internal string? GetNameForUid(string uid)
|
||||
{
|
||||
if (CurrentNotesStorage().UidLastSeenNames.TryGetValue(uid, out var name))
|
||||
{
|
||||
if (string.IsNullOrEmpty(name)) return null;
|
||||
return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
internal HashSet<string> GetServerAvailablePairTags()
|
||||
{
|
||||
return CurrentServerTagStorage().ServerAvailablePairTags;
|
||||
}
|
||||
|
||||
internal ShellConfig GetShellConfigForGid(string gid)
|
||||
{
|
||||
if (CurrentSyncshellStorage().GidShellConfig.TryGetValue(gid, out var config))
|
||||
return config;
|
||||
|
||||
// Pick the next higher syncshell number that is available
|
||||
int newShellNumber = CurrentSyncshellStorage().GidShellConfig.Count > 0 ? CurrentSyncshellStorage().GidShellConfig.Select(x => x.Value.ShellNumber).Max() + 1 : 1;
|
||||
|
||||
var shellConfig = new ShellConfig{
|
||||
ShellNumber = newShellNumber
|
||||
};
|
||||
|
||||
// Save config to avoid auto-generated numbers shuffling around
|
||||
SaveShellConfigForGid(gid, shellConfig);
|
||||
|
||||
return CurrentSyncshellStorage().GidShellConfig[gid];
|
||||
}
|
||||
|
||||
internal int GetShellNumberForGid(string gid)
|
||||
{
|
||||
return GetShellConfigForGid(gid).ShellNumber;
|
||||
}
|
||||
|
||||
internal Dictionary<string, List<string>> GetUidServerPairedUserTags()
|
||||
{
|
||||
return CurrentServerTagStorage().UidServerPairedUserTags;
|
||||
}
|
||||
|
||||
internal HashSet<string> GetUidsForTag(string tag)
|
||||
{
|
||||
return CurrentServerTagStorage().UidServerPairedUserTags.Where(p => p.Value.Contains(tag, StringComparer.Ordinal)).Select(p => p.Key).ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
internal bool HasTags(string uid)
|
||||
{
|
||||
if (CurrentServerTagStorage().UidServerPairedUserTags.TryGetValue(uid, out var tags))
|
||||
{
|
||||
return tags.Any();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal void RemoveCharacterFromServer(int serverSelectionIndex, Authentication item)
|
||||
{
|
||||
var server = GetServerByIndex(serverSelectionIndex);
|
||||
server.Authentications.Remove(item);
|
||||
Save();
|
||||
}
|
||||
|
||||
internal void RemoveOpenPairTag(string tag)
|
||||
{
|
||||
CurrentServerTagStorage().OpenPairTags.Remove(tag);
|
||||
_serverTagConfig.Save();
|
||||
}
|
||||
|
||||
internal void RemoveTag(string tag)
|
||||
{
|
||||
CurrentServerTagStorage().ServerAvailablePairTags.Remove(tag);
|
||||
foreach (var uid in GetUidsForTag(tag))
|
||||
{
|
||||
RemoveTagForUid(uid, tag, save: false);
|
||||
}
|
||||
_serverTagConfig.Save();
|
||||
}
|
||||
|
||||
internal void RemoveTagForUid(string uid, string tagName, bool save = true)
|
||||
{
|
||||
if (CurrentServerTagStorage().UidServerPairedUserTags.TryGetValue(uid, out var tags))
|
||||
{
|
||||
tags.Remove(tagName);
|
||||
|
||||
if (save)
|
||||
{
|
||||
_serverTagConfig.Save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal void RenameTag(string oldName, string newName)
|
||||
{
|
||||
CurrentServerTagStorage().ServerAvailablePairTags.Remove(oldName);
|
||||
CurrentServerTagStorage().ServerAvailablePairTags.Add(newName);
|
||||
foreach (var existingTags in CurrentServerTagStorage().UidServerPairedUserTags.Select(k => k.Value))
|
||||
{
|
||||
if (existingTags.Remove(oldName))
|
||||
existingTags.Add(newName);
|
||||
}
|
||||
}
|
||||
|
||||
internal void SaveNotes()
|
||||
{
|
||||
_notesConfig.Save();
|
||||
}
|
||||
|
||||
internal void SetNoteForGid(string gid, string note, bool save = true)
|
||||
{
|
||||
if (string.IsNullOrEmpty(gid)) return;
|
||||
|
||||
CurrentNotesStorage().GidServerComments[gid] = note;
|
||||
if (save)
|
||||
_notesConfig.Save();
|
||||
}
|
||||
|
||||
internal void SetNoteForUid(string uid, string note, bool save = true)
|
||||
{
|
||||
if (string.IsNullOrEmpty(uid)) return;
|
||||
|
||||
CurrentNotesStorage().UidServerComments[uid] = note;
|
||||
if (save)
|
||||
_notesConfig.Save();
|
||||
}
|
||||
|
||||
internal void SetNameForUid(string uid, string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(uid)) return;
|
||||
|
||||
if (CurrentNotesStorage().UidLastSeenNames.TryGetValue(uid, out var currentName) && currentName.Equals(name, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
CurrentNotesStorage().UidLastSeenNames[uid] = name;
|
||||
_notesConfig.Save();
|
||||
}
|
||||
|
||||
internal void SaveShellConfigForGid(string gid, ShellConfig config)
|
||||
{
|
||||
if (string.IsNullOrEmpty(gid)) return;
|
||||
|
||||
// This is somewhat pointless because ShellConfig is a ref type we returned to the caller anyway...
|
||||
CurrentSyncshellStorage().GidShellConfig[gid] = config;
|
||||
|
||||
_syncshellConfig.Save();
|
||||
}
|
||||
|
||||
internal bool IsUidWhitelisted(string uid)
|
||||
{
|
||||
_cachedWhitelistedUIDs ??= [.. CurrentBlockStorage().Whitelist];
|
||||
return _cachedWhitelistedUIDs.Contains(uid);
|
||||
}
|
||||
|
||||
internal bool IsUidBlacklisted(string uid)
|
||||
{
|
||||
_cachedBlacklistedUIDs ??= [.. CurrentBlockStorage().Blacklist];
|
||||
return _cachedBlacklistedUIDs.Contains(uid);
|
||||
}
|
||||
|
||||
internal void AddWhitelistUid(string uid)
|
||||
{
|
||||
if (IsUidWhitelisted(uid))
|
||||
return;
|
||||
if (CurrentBlockStorage().Blacklist.RemoveAll(u => u.Equals(uid, StringComparison.Ordinal)) > 0)
|
||||
_cachedBlacklistedUIDs = null;
|
||||
CurrentBlockStorage().Whitelist.Add(uid);
|
||||
_cachedWhitelistedUIDs = null;
|
||||
_blockConfig.Save();
|
||||
}
|
||||
|
||||
internal void AddBlacklistUid(string uid)
|
||||
{
|
||||
if (IsUidBlacklisted(uid))
|
||||
return;
|
||||
if (CurrentBlockStorage().Whitelist.RemoveAll(u => u.Equals(uid, StringComparison.Ordinal)) > 0)
|
||||
_cachedWhitelistedUIDs = null;
|
||||
CurrentBlockStorage().Blacklist.Add(uid);
|
||||
_cachedBlacklistedUIDs = null;
|
||||
_blockConfig.Save();
|
||||
}
|
||||
|
||||
internal void RemoveWhitelistUid(string uid)
|
||||
{
|
||||
if (CurrentBlockStorage().Whitelist.RemoveAll(u => u.Equals(uid, StringComparison.Ordinal)) > 0)
|
||||
_cachedWhitelistedUIDs = null;
|
||||
_blockConfig.Save();
|
||||
}
|
||||
|
||||
internal void RemoveBlacklistUid(string uid)
|
||||
{
|
||||
if (CurrentBlockStorage().Blacklist.RemoveAll(u => u.Equals(uid, StringComparison.Ordinal)) > 0)
|
||||
_cachedBlacklistedUIDs = null;
|
||||
_blockConfig.Save();
|
||||
}
|
||||
|
||||
private ServerNotesStorage CurrentNotesStorage()
|
||||
{
|
||||
TryCreateCurrentNotesStorage();
|
||||
return _notesConfig.Current.ServerNotes[CurrentApiUrl];
|
||||
}
|
||||
|
||||
private ServerTagStorage CurrentServerTagStorage()
|
||||
{
|
||||
TryCreateCurrentServerTagStorage();
|
||||
return _serverTagConfig.Current.ServerTagStorage[CurrentApiUrl];
|
||||
}
|
||||
|
||||
private ServerShellStorage CurrentSyncshellStorage()
|
||||
{
|
||||
TryCreateCurrentSyncshellStorage();
|
||||
return _syncshellConfig.Current.ServerShellStorage[CurrentApiUrl];
|
||||
}
|
||||
|
||||
private ServerBlockStorage CurrentBlockStorage()
|
||||
{
|
||||
TryCreateCurrentBlockStorage();
|
||||
return _blockConfig.Current.ServerBlocks[CurrentApiUrl];
|
||||
}
|
||||
|
||||
private void EnsureMainExists()
|
||||
{
|
||||
bool elfExists = false;
|
||||
for (int i = 0; i < _configService.Current.ServerStorage.Count; ++i)
|
||||
{
|
||||
var x = _configService.Current.ServerStorage[i];
|
||||
if (x.ServerUri.Equals(ApiController.ElezenServiceUri, StringComparison.OrdinalIgnoreCase))
|
||||
elfExists = true;
|
||||
}
|
||||
if (!elfExists)
|
||||
{
|
||||
_logger.LogDebug("Re-adding missing server {uri}", ApiController.ElezenServiceUri);
|
||||
_configService.Current.ServerStorage.Insert(0, new ServerStorage() { ServerUri = ApiController.ElezenServiceUri, ServerName = ApiController.ElezenServer });
|
||||
if (_configService.Current.CurrentServer >= 0)
|
||||
_configService.Current.CurrentServer++;
|
||||
}
|
||||
Save();
|
||||
}
|
||||
|
||||
private void TryCreateCurrentNotesStorage()
|
||||
{
|
||||
if (!_notesConfig.Current.ServerNotes.ContainsKey(CurrentApiUrl))
|
||||
{
|
||||
_notesConfig.Current.ServerNotes[CurrentApiUrl] = new();
|
||||
}
|
||||
}
|
||||
|
||||
private void TryCreateCurrentServerTagStorage()
|
||||
{
|
||||
if (!_serverTagConfig.Current.ServerTagStorage.ContainsKey(CurrentApiUrl))
|
||||
{
|
||||
_serverTagConfig.Current.ServerTagStorage[CurrentApiUrl] = new();
|
||||
}
|
||||
}
|
||||
|
||||
private void TryCreateCurrentSyncshellStorage()
|
||||
{
|
||||
if (!_syncshellConfig.Current.ServerShellStorage.ContainsKey(CurrentApiUrl))
|
||||
{
|
||||
_syncshellConfig.Current.ServerShellStorage[CurrentApiUrl] = new();
|
||||
}
|
||||
}
|
||||
|
||||
private void TryCreateCurrentBlockStorage()
|
||||
{
|
||||
if (!_blockConfig.Current.ServerBlocks.ContainsKey(CurrentApiUrl))
|
||||
{
|
||||
_blockConfig.Current.ServerBlocks[CurrentApiUrl] = new();
|
||||
}
|
||||
}
|
||||
}
|
60
MareSynchronos/Services/UiFactory.cs
Normal file
60
MareSynchronos/Services/UiFactory.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using MareSynchronos.API.Dto.Group;
|
||||
using MareSynchronos.PlayerData.Pairs;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using MareSynchronos.Services.ServerConfiguration;
|
||||
using MareSynchronos.UI;
|
||||
using MareSynchronos.UI.Components.Popup;
|
||||
using MareSynchronos.WebAPI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
public class UiFactory
|
||||
{
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly MareMediator _mareMediator;
|
||||
private readonly ApiController _apiController;
|
||||
private readonly UiSharedService _uiSharedService;
|
||||
private readonly PairManager _pairManager;
|
||||
private readonly ServerConfigurationManager _serverConfigManager;
|
||||
private readonly MareProfileManager _mareProfileManager;
|
||||
private readonly PerformanceCollectorService _performanceCollectorService;
|
||||
|
||||
public UiFactory(ILoggerFactory loggerFactory, MareMediator mareMediator, ApiController apiController,
|
||||
UiSharedService uiSharedService, PairManager pairManager, ServerConfigurationManager serverConfigManager,
|
||||
MareProfileManager mareProfileManager, PerformanceCollectorService performanceCollectorService)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_mareMediator = mareMediator;
|
||||
_apiController = apiController;
|
||||
_uiSharedService = uiSharedService;
|
||||
_pairManager = pairManager;
|
||||
_serverConfigManager = serverConfigManager;
|
||||
_mareProfileManager = mareProfileManager;
|
||||
_performanceCollectorService = performanceCollectorService;
|
||||
}
|
||||
|
||||
public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto)
|
||||
{
|
||||
return new SyncshellAdminUI(_loggerFactory.CreateLogger<SyncshellAdminUI>(), _mareMediator,
|
||||
_apiController, _uiSharedService, _pairManager, dto, _performanceCollectorService);
|
||||
}
|
||||
|
||||
public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair)
|
||||
{
|
||||
return new StandaloneProfileUi(_loggerFactory.CreateLogger<StandaloneProfileUi>(), _mareMediator,
|
||||
_uiSharedService, _serverConfigManager, _mareProfileManager, _pairManager, pair, _performanceCollectorService);
|
||||
}
|
||||
|
||||
public PermissionWindowUI CreatePermissionPopupUi(Pair pair)
|
||||
{
|
||||
return new PermissionWindowUI(_loggerFactory.CreateLogger<PermissionWindowUI>(), pair,
|
||||
_mareMediator, _uiSharedService, _apiController, _performanceCollectorService);
|
||||
}
|
||||
|
||||
public PlayerAnalysisUI CreatePlayerAnalysisUi(Pair pair)
|
||||
{
|
||||
return new PlayerAnalysisUI(_loggerFactory.CreateLogger<PlayerAnalysisUI>(), pair,
|
||||
_mareMediator, _uiSharedService, _performanceCollectorService);
|
||||
}
|
||||
}
|
137
MareSynchronos/Services/UiService.cs
Normal file
137
MareSynchronos/Services/UiService.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.ImGuiFileDialog;
|
||||
using Dalamud.Interface.Windowing;
|
||||
using MareSynchronos.MareConfiguration;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using MareSynchronos.UI;
|
||||
using MareSynchronos.UI.Components.Popup;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
public sealed class UiService : DisposableMediatorSubscriberBase
|
||||
{
|
||||
private readonly List<WindowMediatorSubscriberBase> _createdWindows = [];
|
||||
private readonly IUiBuilder _uiBuilder;
|
||||
private readonly FileDialogManager _fileDialogManager;
|
||||
private readonly ILogger<UiService> _logger;
|
||||
private readonly MareConfigService _mareConfigService;
|
||||
private readonly WindowSystem _windowSystem;
|
||||
private readonly UiFactory _uiFactory;
|
||||
|
||||
public UiService(ILogger<UiService> logger, IUiBuilder uiBuilder,
|
||||
MareConfigService mareConfigService, WindowSystem windowSystem,
|
||||
IEnumerable<WindowMediatorSubscriberBase> windows,
|
||||
UiFactory uiFactory, FileDialogManager fileDialogManager,
|
||||
MareMediator mareMediator) : base(logger, mareMediator)
|
||||
{
|
||||
_logger = logger;
|
||||
_logger.LogTrace("Creating {type}", GetType().Name);
|
||||
_uiBuilder = uiBuilder;
|
||||
_mareConfigService = mareConfigService;
|
||||
_windowSystem = windowSystem;
|
||||
_uiFactory = uiFactory;
|
||||
_fileDialogManager = fileDialogManager;
|
||||
|
||||
_uiBuilder.DisableGposeUiHide = true;
|
||||
_uiBuilder.Draw += Draw;
|
||||
_uiBuilder.OpenConfigUi += ToggleUi;
|
||||
_uiBuilder.OpenMainUi += ToggleMainUi;
|
||||
|
||||
foreach (var window in windows)
|
||||
{
|
||||
_windowSystem.AddWindow(window);
|
||||
}
|
||||
|
||||
Mediator.Subscribe<ProfileOpenStandaloneMessage>(this, (msg) =>
|
||||
{
|
||||
if (!_createdWindows.Exists(p => p is StandaloneProfileUi ui
|
||||
&& string.Equals(ui.Pair.UserData.AliasOrUID, msg.Pair.UserData.AliasOrUID, StringComparison.Ordinal)))
|
||||
{
|
||||
var window = _uiFactory.CreateStandaloneProfileUi(msg.Pair);
|
||||
_createdWindows.Add(window);
|
||||
_windowSystem.AddWindow(window);
|
||||
}
|
||||
});
|
||||
|
||||
Mediator.Subscribe<OpenSyncshellAdminPanel>(this, (msg) =>
|
||||
{
|
||||
if (!_createdWindows.Exists(p => p is SyncshellAdminUI ui
|
||||
&& string.Equals(ui.GroupFullInfo.GID, msg.GroupInfo.GID, StringComparison.Ordinal)))
|
||||
{
|
||||
var window = _uiFactory.CreateSyncshellAdminUi(msg.GroupInfo);
|
||||
_createdWindows.Add(window);
|
||||
_windowSystem.AddWindow(window);
|
||||
}
|
||||
});
|
||||
|
||||
Mediator.Subscribe<OpenPermissionWindow>(this, (msg) =>
|
||||
{
|
||||
if (!_createdWindows.Exists(p => p is PermissionWindowUI ui
|
||||
&& msg.Pair == ui.Pair))
|
||||
{
|
||||
var window = _uiFactory.CreatePermissionPopupUi(msg.Pair);
|
||||
_createdWindows.Add(window);
|
||||
_windowSystem.AddWindow(window);
|
||||
}
|
||||
});
|
||||
|
||||
Mediator.Subscribe<OpenPairAnalysisWindow>(this, (msg) =>
|
||||
{
|
||||
if (!_createdWindows.Exists(p => p is PlayerAnalysisUI ui
|
||||
&& msg.Pair == ui.Pair))
|
||||
{
|
||||
var window = _uiFactory.CreatePlayerAnalysisUi(msg.Pair);
|
||||
_createdWindows.Add(window);
|
||||
_windowSystem.AddWindow(window);
|
||||
}
|
||||
});
|
||||
|
||||
Mediator.Subscribe<RemoveWindowMessage>(this, (msg) =>
|
||||
{
|
||||
_windowSystem.RemoveWindow(msg.Window);
|
||||
_createdWindows.Remove(msg.Window);
|
||||
msg.Window.Dispose();
|
||||
});
|
||||
}
|
||||
|
||||
public void ToggleMainUi()
|
||||
{
|
||||
if (_mareConfigService.Current.HasValidSetup())
|
||||
Mediator.Publish(new UiToggleMessage(typeof(CompactUi)));
|
||||
else
|
||||
Mediator.Publish(new UiToggleMessage(typeof(IntroUi)));
|
||||
}
|
||||
|
||||
public void ToggleUi()
|
||||
{
|
||||
if (_mareConfigService.Current.HasValidSetup())
|
||||
Mediator.Publish(new UiToggleMessage(typeof(SettingsUi)));
|
||||
else
|
||||
Mediator.Publish(new UiToggleMessage(typeof(IntroUi)));
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
_logger.LogTrace("Disposing {type}", GetType().Name);
|
||||
|
||||
_windowSystem.RemoveAllWindows();
|
||||
|
||||
foreach (var window in _createdWindows)
|
||||
{
|
||||
window.Dispose();
|
||||
}
|
||||
|
||||
_uiBuilder.Draw -= Draw;
|
||||
_uiBuilder.OpenConfigUi -= ToggleUi;
|
||||
_uiBuilder.OpenMainUi -= ToggleMainUi;
|
||||
}
|
||||
|
||||
private void Draw()
|
||||
{
|
||||
_windowSystem.Draw();
|
||||
_fileDialogManager.Draw();
|
||||
}
|
||||
}
|
105
MareSynchronos/Services/VisibilityService.cs
Normal file
105
MareSynchronos/Services/VisibilityService.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using MareSynchronos.Interop.Ipc;
|
||||
using MareSynchronos.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
// Detect when players of interest are visible
|
||||
public class VisibilityService : DisposableMediatorSubscriberBase
|
||||
{
|
||||
private enum TrackedPlayerStatus
|
||||
{
|
||||
NotVisible,
|
||||
Visible,
|
||||
MareHandled
|
||||
};
|
||||
|
||||
private readonly DalamudUtilService _dalamudUtil;
|
||||
private readonly ConcurrentDictionary<string, TrackedPlayerStatus> _trackedPlayerVisibility = new(StringComparer.Ordinal);
|
||||
private readonly List<string> _makeVisibleNextFrame = new();
|
||||
private readonly IpcCallerMare _mare;
|
||||
private readonly HashSet<nint> cachedMareAddresses = new();
|
||||
private uint _cachedAddressSum = 0;
|
||||
private uint _cachedAddressSumDebounce = 1;
|
||||
|
||||
public VisibilityService(ILogger<VisibilityService> logger, MareMediator mediator, IpcCallerMare mare, DalamudUtilService dalamudUtil)
|
||||
: base(logger, mediator)
|
||||
{
|
||||
_mare = mare;
|
||||
_dalamudUtil = dalamudUtil;
|
||||
Mediator.Subscribe<FrameworkUpdateMessage>(this, (_) => FrameworkUpdate());
|
||||
}
|
||||
|
||||
public void StartTracking(string ident)
|
||||
{
|
||||
_trackedPlayerVisibility.TryAdd(ident, TrackedPlayerStatus.NotVisible);
|
||||
}
|
||||
|
||||
public void StopTracking(string ident)
|
||||
{
|
||||
// No PairVisibilityMessage is emitted if the player was visible when removed
|
||||
_trackedPlayerVisibility.TryRemove(ident, out _);
|
||||
}
|
||||
|
||||
private void FrameworkUpdate()
|
||||
{
|
||||
var mareHandledAddresses = _mare.GetHandledGameAddresses();
|
||||
uint addressSum = 0;
|
||||
|
||||
foreach (var addr in mareHandledAddresses)
|
||||
addressSum ^= (uint)addr.GetHashCode();
|
||||
|
||||
if (addressSum != _cachedAddressSum)
|
||||
{
|
||||
if (addressSum == _cachedAddressSumDebounce)
|
||||
{
|
||||
cachedMareAddresses.Clear();
|
||||
foreach (var addr in mareHandledAddresses)
|
||||
cachedMareAddresses.Add(addr);
|
||||
_cachedAddressSum = addressSum;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cachedAddressSumDebounce = addressSum;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var player in _trackedPlayerVisibility)
|
||||
{
|
||||
string ident = player.Key;
|
||||
var findResult = _dalamudUtil.FindPlayerByNameHash(ident);
|
||||
var isMareHandled = cachedMareAddresses.Contains(findResult.Address);
|
||||
var isVisible = findResult.ObjectId != 0 && !isMareHandled;
|
||||
|
||||
if (player.Value == TrackedPlayerStatus.MareHandled && !isMareHandled)
|
||||
_trackedPlayerVisibility.TryUpdate(ident, newValue: TrackedPlayerStatus.NotVisible, comparisonValue: TrackedPlayerStatus.MareHandled);
|
||||
|
||||
if (player.Value == TrackedPlayerStatus.NotVisible && isVisible)
|
||||
{
|
||||
if (_makeVisibleNextFrame.Contains(ident))
|
||||
{
|
||||
if (_trackedPlayerVisibility.TryUpdate(ident, newValue: TrackedPlayerStatus.Visible, comparisonValue: TrackedPlayerStatus.NotVisible))
|
||||
Mediator.Publish<PlayerVisibilityMessage>(new(ident, IsVisible: true));
|
||||
}
|
||||
else
|
||||
_makeVisibleNextFrame.Add(ident);
|
||||
}
|
||||
else if (player.Value == TrackedPlayerStatus.NotVisible && isMareHandled)
|
||||
{
|
||||
// Send a technically redundant visibility update with the added intent of triggering PairHandler to undo the application by name
|
||||
if (_trackedPlayerVisibility.TryUpdate(ident, newValue: TrackedPlayerStatus.MareHandled, comparisonValue: TrackedPlayerStatus.NotVisible))
|
||||
Mediator.Publish<PlayerVisibilityMessage>(new(ident, IsVisible: false, Invalidate: true));
|
||||
}
|
||||
else if (player.Value == TrackedPlayerStatus.Visible && !isVisible)
|
||||
{
|
||||
var newTrackedStatus = isMareHandled ? TrackedPlayerStatus.MareHandled : TrackedPlayerStatus.NotVisible;
|
||||
if (_trackedPlayerVisibility.TryUpdate(ident, newValue: newTrackedStatus, comparisonValue: TrackedPlayerStatus.Visible))
|
||||
Mediator.Publish<PlayerVisibilityMessage>(new(ident, IsVisible: false, Invalidate: isMareHandled));
|
||||
}
|
||||
|
||||
if (!isVisible)
|
||||
_makeVisibleNextFrame.Remove(ident);
|
||||
}
|
||||
}
|
||||
}
|
257
MareSynchronos/Services/XivDataAnalyzer.cs
Normal file
257
MareSynchronos/Services/XivDataAnalyzer.cs
Normal file
@@ -0,0 +1,257 @@
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
||||
using FFXIVClientStructs.Havok.Animation;
|
||||
using FFXIVClientStructs.Havok.Common.Base.Types;
|
||||
using FFXIVClientStructs.Havok.Common.Serialize.Util;
|
||||
using Lumina.Data;
|
||||
using MareSynchronos.FileCache;
|
||||
using MareSynchronos.Interop.GameModel;
|
||||
using MareSynchronos.MareConfiguration;
|
||||
using MareSynchronos.PlayerData.Handlers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace MareSynchronos.Services;
|
||||
|
||||
public sealed class XivDataAnalyzer
|
||||
{
|
||||
private readonly ILogger<XivDataAnalyzer> _logger;
|
||||
private readonly FileCacheManager _fileCacheManager;
|
||||
private readonly XivDataStorageService _configService;
|
||||
private readonly List<string> _failedCalculatedTris = [];
|
||||
private readonly List<string> _failedCalculatedTex = [];
|
||||
|
||||
public XivDataAnalyzer(ILogger<XivDataAnalyzer> logger, FileCacheManager fileCacheManager,
|
||||
XivDataStorageService configService)
|
||||
{
|
||||
_logger = logger;
|
||||
_fileCacheManager = fileCacheManager;
|
||||
_configService = configService;
|
||||
}
|
||||
|
||||
public unsafe Dictionary<string, List<ushort>>? GetSkeletonBoneIndices(GameObjectHandler handler)
|
||||
{
|
||||
if (handler.Address == nint.Zero) return null;
|
||||
var chara = (CharacterBase*)(((Character*)handler.Address)->GameObject.DrawObject);
|
||||
if (chara->GetModelType() != CharacterBase.ModelType.Human) return null;
|
||||
var resHandles = chara->Skeleton->SkeletonResourceHandles;
|
||||
Dictionary<string, List<ushort>> outputIndices = [];
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < chara->Skeleton->PartialSkeletonCount; i++)
|
||||
{
|
||||
var handle = *(resHandles + i);
|
||||
_logger.LogTrace("Iterating over SkeletonResourceHandle #{i}:{x}", i, ((nint)handle).ToString("X"));
|
||||
if ((nint)handle == nint.Zero) continue;
|
||||
var curBones = handle->BoneCount;
|
||||
// this is unrealistic, the filename shouldn't ever be that long
|
||||
if (handle->FileName.Length > 1024) continue;
|
||||
var skeletonName = handle->FileName.ToString();
|
||||
if (string.IsNullOrEmpty(skeletonName)) continue;
|
||||
outputIndices[skeletonName] = new();
|
||||
for (ushort boneIdx = 0; boneIdx < curBones; boneIdx++)
|
||||
{
|
||||
var boneName = handle->HavokSkeleton->Bones[boneIdx].Name.String;
|
||||
if (boneName == null) continue;
|
||||
outputIndices[skeletonName].Add((ushort)(boneIdx + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not process skeleton data");
|
||||
}
|
||||
|
||||
return (outputIndices.Count != 0 && outputIndices.Values.All(u => u.Count > 0)) ? outputIndices : null;
|
||||
}
|
||||
|
||||
public unsafe Dictionary<string, List<ushort>>? GetBoneIndicesFromPap(string hash)
|
||||
{
|
||||
if (_configService.Current.BonesDictionary.TryGetValue(hash, out var bones)) return bones;
|
||||
|
||||
var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash, preferSubst: true);
|
||||
if (cacheEntity == null) return null;
|
||||
|
||||
using BinaryReader reader = new BinaryReader(File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read));
|
||||
|
||||
// most of this shit is from vfxeditor, surely nothing will change in the pap format :copium:
|
||||
reader.ReadInt32(); // ignore
|
||||
reader.ReadInt32(); // ignore
|
||||
reader.ReadInt16(); // read 2 (num animations)
|
||||
reader.ReadInt16(); // read 2 (modelid)
|
||||
var type = reader.ReadByte();// read 1 (type)
|
||||
if (type != 0) return null; // it's not human, just ignore it, whatever
|
||||
|
||||
reader.ReadByte(); // read 1 (variant)
|
||||
reader.ReadInt32(); // ignore
|
||||
var havokPosition = reader.ReadInt32();
|
||||
var footerPosition = reader.ReadInt32();
|
||||
var havokDataSize = footerPosition - havokPosition;
|
||||
reader.BaseStream.Position = havokPosition;
|
||||
var havokData = reader.ReadBytes(havokDataSize);
|
||||
if (havokData.Length <= 8) return null; // no havok data
|
||||
|
||||
var output = new Dictionary<string, List<ushort>>(StringComparer.OrdinalIgnoreCase);
|
||||
var tempHavokDataPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()) + ".hkx";
|
||||
var tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath);
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(tempHavokDataPath, havokData);
|
||||
|
||||
var loadoptions = stackalloc hkSerializeUtil.LoadOptions[1];
|
||||
loadoptions->TypeInfoRegistry = hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry();
|
||||
loadoptions->ClassNameRegistry = hkBuiltinTypeRegistry.Instance()->GetClassNameRegistry();
|
||||
loadoptions->Flags = new hkFlags<hkSerializeUtil.LoadOptionBits, int>
|
||||
{
|
||||
Storage = (int)(hkSerializeUtil.LoadOptionBits.Default)
|
||||
};
|
||||
|
||||
var resource = hkSerializeUtil.LoadFromFile((byte*)tempHavokDataPathAnsi, null, loadoptions);
|
||||
if (resource == null)
|
||||
{
|
||||
throw new InvalidOperationException("Resource was null after loading");
|
||||
}
|
||||
|
||||
var rootLevelName = @"hkRootLevelContainer"u8;
|
||||
fixed (byte* n1 = rootLevelName)
|
||||
{
|
||||
var container = (hkRootLevelContainer*)resource->GetContentsPointer(n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry());
|
||||
var animationName = @"hkaAnimationContainer"u8;
|
||||
fixed (byte* n2 = animationName)
|
||||
{
|
||||
var animContainer = (hkaAnimationContainer*)container->findObjectByName(n2, null);
|
||||
for (int i = 0; i < animContainer->Bindings.Length; i++)
|
||||
{
|
||||
var binding = animContainer->Bindings[i].ptr;
|
||||
var boneTransform = binding->TransformTrackToBoneIndices;
|
||||
string name = binding->OriginalSkeletonName.String! + "_" + i;
|
||||
output[name] = [];
|
||||
for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++)
|
||||
{
|
||||
output[name].Add((ushort)boneTransform[boneIdx]);
|
||||
}
|
||||
output[name].Sort();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not load havok file in {path}", tempHavokDataPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(tempHavokDataPathAnsi);
|
||||
File.Delete(tempHavokDataPath);
|
||||
}
|
||||
|
||||
_configService.Current.BonesDictionary[hash] = output;
|
||||
_configService.Save();
|
||||
return output;
|
||||
}
|
||||
|
||||
public long GetTrianglesByHash(string hash)
|
||||
{
|
||||
if (_configService.Current.TriangleDictionary.TryGetValue(hash, out var cachedTris) && cachedTris > 0)
|
||||
return cachedTris;
|
||||
|
||||
if (_failedCalculatedTris.Contains(hash, StringComparer.Ordinal))
|
||||
return 0;
|
||||
|
||||
var path = _fileCacheManager.GetFileCacheByHash(hash, preferSubst: true);
|
||||
if (path == null || !path.ResolvedFilepath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase))
|
||||
return 0;
|
||||
|
||||
var filePath = path.ResolvedFilepath;
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Detected Model File {path}, calculating Tris", filePath);
|
||||
var file = new MdlFile(filePath);
|
||||
if (file.LodCount <= 0)
|
||||
{
|
||||
_failedCalculatedTris.Add(hash);
|
||||
_configService.Current.TriangleDictionary[hash] = 0;
|
||||
_configService.Save();
|
||||
return 0;
|
||||
}
|
||||
|
||||
long tris = 0;
|
||||
for (int i = 0; i < file.LodCount; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var meshIdx = file.Lods[i].MeshIndex;
|
||||
var meshCnt = file.Lods[i].MeshCount;
|
||||
tris = file.Meshes.Skip(meshIdx).Take(meshCnt).Sum(p => p.IndexCount) / 3;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Could not load lod mesh {mesh} from path {path}", i, filePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tris > 0)
|
||||
{
|
||||
_logger.LogDebug("TriAnalysis: {filePath} => {tris} triangles", filePath, tris);
|
||||
_configService.Current.TriangleDictionary[hash] = tris;
|
||||
_configService.Save();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return tris;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_failedCalculatedTris.Add(hash);
|
||||
_configService.Current.TriangleDictionary[hash] = 0;
|
||||
_configService.Save();
|
||||
_logger.LogWarning(e, "Could not parse file {file}", filePath);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public (uint Format, int MipCount, ushort Width, ushort Height) GetTexFormatByHash(string hash)
|
||||
{
|
||||
if (_configService.Current.TexDictionary.TryGetValue(hash, out var cachedTex) && cachedTex.Mip0Size > 0)
|
||||
return cachedTex;
|
||||
|
||||
if (_failedCalculatedTex.Contains(hash, StringComparer.Ordinal))
|
||||
return default;
|
||||
|
||||
var path = _fileCacheManager.GetFileCacheByHash(hash);
|
||||
if (path == null || !path.ResolvedFilepath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase))
|
||||
return default;
|
||||
|
||||
var filePath = path.ResolvedFilepath;
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Detected Texture File {path}, reading header", filePath);
|
||||
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
|
||||
using var r = new LuminaBinaryReader(stream);
|
||||
var texHeader = r.ReadStructure<Lumina.Data.Files.TexFile.TexHeader>();
|
||||
|
||||
if (texHeader.Format == default || texHeader.MipCount == 0 || texHeader.ArraySize != 0 || texHeader.MipCount > 13)
|
||||
{
|
||||
_failedCalculatedTex.Add(hash);
|
||||
_configService.Current.TexDictionary[hash] = default;
|
||||
_configService.Save();
|
||||
return default;
|
||||
}
|
||||
|
||||
return ((uint)texHeader.Format, texHeader.MipCount, texHeader.Width, texHeader.Height);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_failedCalculatedTex.Add(hash);
|
||||
_configService.Current.TriangleDictionary[hash] = 0;
|
||||
_configService.Save();
|
||||
_logger.LogWarning(e, "Could not parse file {file}", filePath);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user