diff --git a/MareAPI b/MareAPI index 9c9b7d9..c8cc217 160000 --- a/MareAPI +++ b/MareAPI @@ -1 +1 @@ -Subproject commit 9c9b7d90c1d242bfae3d1df6083409ed8786c841 +Subproject commit c8cc217d664102e8659f316914bb272e2d7e0a7c diff --git a/MareSynchronos/.editorconfig b/MareSynchronos/.editorconfig index 441acd7..498103c 100644 --- a/MareSynchronos/.editorconfig +++ b/MareSynchronos/.editorconfig @@ -112,3 +112,6 @@ dotnet_diagnostic.S6667.severity = suggestion # IDE0290: Use primary constructor csharp_style_prefer_primary_constructors = false + +# S3267: Loops should be simplified with "LINQ" expressions +dotnet_diagnostic.S3267.severity = silent diff --git a/MareSynchronos/GlobalSuppressions.cs b/MareSynchronos/GlobalSuppressions.cs new file mode 100644 index 0000000..ac112b6 --- /dev/null +++ b/MareSynchronos/GlobalSuppressions.cs @@ -0,0 +1,8 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "", Scope = "member", Target = "~M:MareSynchronos.Services.CharaDataManager.AttachPoseData(MareSynchronos.API.Dto.CharaData.PoseEntry,MareSynchronos.Services.CharaData.Models.CharaDataExtendedUpdateDto)")] diff --git a/MareSynchronos/Interop/BlockedCharacterHandler.cs b/MareSynchronos/Interop/BlockedCharacterHandler.cs index 4babaae..f8d348f 100644 --- a/MareSynchronos/Interop/BlockedCharacterHandler.cs +++ b/MareSynchronos/Interop/BlockedCharacterHandler.cs @@ -35,7 +35,7 @@ public unsafe class BlockedCharacterHandler firstTime = true; var blockStatus = InfoProxyBlacklist.Instance()->GetBlockResultType(combined.AccId, combined.ContentId); _logger.LogTrace("CharaPtr {ptr} is BlockStatus: {status}", ptr, blockStatus); - if ((int)blockStatus == 0) + if ((int)blockStatus == 0) return false; return _blockedCharacterCache[combined] = blockStatus != InfoProxyBlacklist.BlockResultType.NotBlocked; } diff --git a/MareSynchronos/Interop/Ipc/IpcCallerBrio.cs b/MareSynchronos/Interop/Ipc/IpcCallerBrio.cs new file mode 100644 index 0000000..6f6b11d --- /dev/null +++ b/MareSynchronos/Interop/Ipc/IpcCallerBrio.cs @@ -0,0 +1,146 @@ +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Plugin; +using Dalamud.Plugin.Ipc; +using MareSynchronos.API.Dto.CharaData; +using MareSynchronos.Services; +using Microsoft.Extensions.Logging; +using System.Numerics; +using System.Text.Json.Nodes; + +namespace MareSynchronos.Interop.Ipc; + +public sealed class IpcCallerBrio : IIpcCaller +{ + private readonly ILogger _logger; + private readonly DalamudUtilService _dalamudUtilService; + private readonly ICallGateSubscriber<(int, int)> _brioApiVersion; + + private readonly ICallGateSubscriber> _brioSpawnActorAsync; + private readonly ICallGateSubscriber _brioDespawnActor; + private readonly ICallGateSubscriber _brioSetModelTransform; + private readonly ICallGateSubscriber _brioGetModelTransform; + private readonly ICallGateSubscriber _brioGetPoseAsJson; + private readonly ICallGateSubscriber _brioSetPoseFromJson; + private readonly ICallGateSubscriber _brioFreezeActor; + private readonly ICallGateSubscriber _brioFreezePhysics; + + + public bool APIAvailable { get; private set; } + + public IpcCallerBrio(ILogger logger, IDalamudPluginInterface dalamudPluginInterface, + DalamudUtilService dalamudUtilService) + { + _logger = logger; + _dalamudUtilService = dalamudUtilService; + + _brioApiVersion = dalamudPluginInterface.GetIpcSubscriber<(int, int)>("Brio.ApiVersion"); + _brioSpawnActorAsync = dalamudPluginInterface.GetIpcSubscriber>("Brio.Actor.SpawnExAsync"); + _brioDespawnActor = dalamudPluginInterface.GetIpcSubscriber("Brio.Actor.Despawn"); + _brioSetModelTransform = dalamudPluginInterface.GetIpcSubscriber("Brio.Actor.SetModelTransform"); + _brioGetModelTransform = dalamudPluginInterface.GetIpcSubscriber("Brio.Actor.GetModelTransform"); + _brioGetPoseAsJson = dalamudPluginInterface.GetIpcSubscriber("Brio.Actor.Pose.GetPoseAsJson"); + _brioSetPoseFromJson = dalamudPluginInterface.GetIpcSubscriber("Brio.Actor.Pose.LoadFromJson"); + _brioFreezeActor = dalamudPluginInterface.GetIpcSubscriber("Brio.Actor.Freeze"); + _brioFreezePhysics = dalamudPluginInterface.GetIpcSubscriber("Brio.FreezePhysics"); + + CheckAPI(); + } + + public void CheckAPI() + { + try + { + var version = _brioApiVersion.InvokeFunc(); + APIAvailable = (version.Item1 == 2 && version.Item2 >= 0); + } + catch + { + APIAvailable = false; + } + } + + public async Task SpawnActorAsync() + { + if (!APIAvailable) return null; + _logger.LogDebug("Spawning Brio Actor"); + return await _brioSpawnActorAsync.InvokeFunc(false, false, true).ConfigureAwait(false); + } + + public async Task DespawnActorAsync(nint address) + { + if (!APIAvailable) return false; + var gameObject = await _dalamudUtilService.CreateGameObjectAsync(address).ConfigureAwait(false); + if (gameObject == null) return false; + _logger.LogDebug("Despawning Brio Actor {actor}", gameObject.Name.TextValue); + return await _dalamudUtilService.RunOnFrameworkThread(() => _brioDespawnActor.InvokeFunc(gameObject)).ConfigureAwait(false); + } + + public async Task ApplyTransformAsync(nint address, WorldData data) + { + if (!APIAvailable) return false; + var gameObject = await _dalamudUtilService.CreateGameObjectAsync(address).ConfigureAwait(false); + if (gameObject == null) return false; + _logger.LogDebug("Applying Transform to Actor {actor}", gameObject.Name.TextValue); + + return await _dalamudUtilService.RunOnFrameworkThread(() => _brioSetModelTransform.InvokeFunc(gameObject, + new Vector3(data.PositionX, data.PositionY, data.PositionZ), + new Quaternion(data.RotationX, data.RotationY, data.RotationZ, data.RotationW), + new Vector3(data.ScaleX, data.ScaleY, data.ScaleZ), false)).ConfigureAwait(false); + } + + public async Task GetTransformAsync(nint address) + { + if (!APIAvailable) return default; + var gameObject = await _dalamudUtilService.CreateGameObjectAsync(address).ConfigureAwait(false); + if (gameObject == null) return default; + var data = await _dalamudUtilService.RunOnFrameworkThread(() => _brioGetModelTransform.InvokeFunc(gameObject)).ConfigureAwait(false); + _logger.LogDebug("Getting Transform from Actor {actor}", gameObject.Name.TextValue); + + return new WorldData() + { + PositionX = data.Item1.Value.X, + PositionY = data.Item1.Value.Y, + PositionZ = data.Item1.Value.Z, + RotationX = data.Item2.Value.X, + RotationY = data.Item2.Value.Y, + RotationZ = data.Item2.Value.Z, + RotationW = data.Item2.Value.W, + ScaleX = data.Item3.Value.X, + ScaleY = data.Item3.Value.Y, + ScaleZ = data.Item3.Value.Z + }; + } + + public async Task GetPoseAsync(nint address) + { + if (!APIAvailable) return null; + var gameObject = await _dalamudUtilService.CreateGameObjectAsync(address).ConfigureAwait(false); + if (gameObject == null) return null; + _logger.LogDebug("Getting Pose from Actor {actor}", gameObject.Name.TextValue); + + return await _dalamudUtilService.RunOnFrameworkThread(() => _brioGetPoseAsJson.InvokeFunc(gameObject)).ConfigureAwait(false); + } + + public async Task SetPoseAsync(nint address, string pose) + { + if (!APIAvailable) return false; + var gameObject = await _dalamudUtilService.CreateGameObjectAsync(address).ConfigureAwait(false); + if (gameObject == null) return false; + _logger.LogDebug("Setting Pose to Actor {actor}", gameObject.Name.TextValue); + + var applicablePose = JsonNode.Parse(pose)!; + var currentPose = await _dalamudUtilService.RunOnFrameworkThread(() => _brioGetPoseAsJson.InvokeFunc(gameObject)).ConfigureAwait(false); + applicablePose["ModelDifference"] = JsonNode.Parse(JsonNode.Parse(currentPose)!["ModelDifference"]!.ToJsonString()); + + await _dalamudUtilService.RunOnFrameworkThread(() => + { + _brioFreezeActor.InvokeFunc(gameObject); + _brioFreezePhysics.InvokeFunc(); + }).ConfigureAwait(false); + return await _dalamudUtilService.RunOnFrameworkThread(() => _brioSetPoseFromJson.InvokeFunc(gameObject, applicablePose.ToJsonString(), false)).ConfigureAwait(false); + } + + public void Dispose() + { + } +} diff --git a/MareSynchronos/Interop/Ipc/IpcManager.cs b/MareSynchronos/Interop/Ipc/IpcManager.cs index 47d7709..dcb4ee1 100644 --- a/MareSynchronos/Interop/Ipc/IpcManager.cs +++ b/MareSynchronos/Interop/Ipc/IpcManager.cs @@ -7,15 +7,16 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase { public IpcManager(ILogger logger, MareMediator mediator, IpcCallerPenumbra penumbraIpc, IpcCallerGlamourer glamourerIpc, IpcCallerCustomize customizeIpc, IpcCallerHeels heelsIpc, - IpcCallerHonorific honorificIpc, IpcCallerPetNames ipcCallerPetNames, IpcCallerMoodles moodlesIpc) : base(logger, mediator) + IpcCallerHonorific honorificIpc, IpcCallerMoodles moodlesIpc, IpcCallerPetNames ipcCallerPetNames, IpcCallerBrio ipcCallerBrio) : base(logger, mediator) { CustomizePlus = customizeIpc; Heels = heelsIpc; Glamourer = glamourerIpc; Penumbra = penumbraIpc; Honorific = honorificIpc; - PetNames = ipcCallerPetNames; Moodles = moodlesIpc; + PetNames = ipcCallerPetNames; + Brio = ipcCallerBrio; if (Initialized) { @@ -41,15 +42,17 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase public IpcCallerHeels Heels { get; init; } public IpcCallerGlamourer Glamourer { get; } public IpcCallerPenumbra Penumbra { get; } - public IpcCallerPetNames PetNames { get; } public IpcCallerMoodles Moodles { get; } + public IpcCallerPetNames PetNames { get; } + + public IpcCallerBrio Brio { get; } private int _stateCheckCounter = -1; private void PeriodicApiStateCheck() { // Stagger API checks - if (++_stateCheckCounter > 7) + if (++_stateCheckCounter > 8) _stateCheckCounter = 0; int i = _stateCheckCounter; if (i == 0) Penumbra.CheckAPI(); @@ -58,7 +61,8 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase if (i == 3) Heels.CheckAPI(); if (i == 4) CustomizePlus.CheckAPI(); if (i == 5) Honorific.CheckAPI(); - if (i == 6) PetNames.CheckAPI(); - if (i == 7) Moodles.CheckAPI(); + if (i == 6) Moodles.CheckAPI(); + if (i == 7) PetNames.CheckAPI(); + if (i == 8) Brio.CheckAPI(); } } \ No newline at end of file diff --git a/MareSynchronos/Interop/Ipc/IpcProvider.cs b/MareSynchronos/Interop/Ipc/IpcProvider.cs index 4722cd7..79a3e0d 100644 --- a/MareSynchronos/Interop/Ipc/IpcProvider.cs +++ b/MareSynchronos/Interop/Ipc/IpcProvider.cs @@ -2,7 +2,6 @@ using Dalamud.Plugin; using Dalamud.Plugin.Ipc; using MareSynchronos.MareConfiguration; -using MareSynchronos.PlayerData.Export; using MareSynchronos.PlayerData.Handlers; using MareSynchronos.Services; using MareSynchronos.Services.Mediator; @@ -16,7 +15,6 @@ public class IpcProvider : IHostedService, IMediatorSubscriber private readonly ILogger _logger; private readonly IDalamudPluginInterface _pi; private readonly MareConfigService _mareConfig; - private readonly MareCharaFileManager _mareCharaFileManager; private readonly DalamudUtilService _dalamudUtil; private ICallGateProvider? _loadFileProvider; private ICallGateProvider>? _loadFileAsyncProvider; @@ -36,16 +34,17 @@ public class IpcProvider : IHostedService, IMediatorSubscriber public MareMediator Mediator { get; init; } public IpcProvider(ILogger logger, IDalamudPluginInterface pi, MareConfigService mareConfig, - MareCharaFileManager mareCharaFileManager, DalamudUtilService dalamudUtil, + DalamudUtilService dalamudUtil, MareMediator mareMediator) { _logger = logger; _pi = pi; _mareConfig = mareConfig; - _mareCharaFileManager = mareCharaFileManager; _dalamudUtil = dalamudUtil; Mediator = mareMediator; + // todo: fix ipc to use CharaDataManager + Mediator.Subscribe(this, (msg) => { if (msg.OwnedObject) return; @@ -136,7 +135,7 @@ public class IpcProvider : IHostedService, IMediatorSubscriber private async Task LoadMcdfAsync(string path, IGameObject target) { - if (_mareCharaFileManager.CurrentlyWorking || !_dalamudUtil.IsInGpose) + //if (_mareCharaFileManager.CurrentlyWorking || !_dalamudUtil.IsInGpose) return false; await ApplyFileAsync(path, target).ConfigureAwait(false); @@ -146,7 +145,7 @@ public class IpcProvider : IHostedService, IMediatorSubscriber private bool LoadMcdf(string path, IGameObject target) { - if (_mareCharaFileManager.CurrentlyWorking || !_dalamudUtil.IsInGpose) + //if (_mareCharaFileManager.CurrentlyWorking || !_dalamudUtil.IsInGpose) return false; _ = Task.Run(async () => await ApplyFileAsync(path, target).ConfigureAwait(false)).ConfigureAwait(false); @@ -156,6 +155,7 @@ public class IpcProvider : IHostedService, IMediatorSubscriber private async Task ApplyFileAsync(string path, IGameObject target) { + /* try { var expectedLength = _mareCharaFileManager.LoadMareCharaFile(path); @@ -168,7 +168,7 @@ public class IpcProvider : IHostedService, IMediatorSubscriber finally { _mareCharaFileManager.ClearMareCharaFile(); - } + }*/ } private List GetHandledAddresses() diff --git a/MareSynchronos/Interop/VfxSpawnManager.cs b/MareSynchronos/Interop/VfxSpawnManager.cs new file mode 100644 index 0000000..18cd0f0 --- /dev/null +++ b/MareSynchronos/Interop/VfxSpawnManager.cs @@ -0,0 +1,179 @@ +using Dalamud.Memory; +using Dalamud.Plugin.Services; +using Dalamud.Utility.Signatures; +using MareSynchronos.Services.Mediator; +using Microsoft.Extensions.Logging; +using System.Numerics; +using System.Runtime.InteropServices; +using System.Text; + +namespace MareSynchronos.Interop; + +/// +/// Code for spawning mostly taken from https://git.anna.lgbt/anna/OrangeGuidanceTomestone/src/branch/main/client/Vfx.cs +/// +public unsafe class VfxSpawnManager : DisposableMediatorSubscriberBase +{ + private static readonly byte[] _pool = "Client.System.Scheduler.Instance.VfxObject\0"u8.ToArray(); + + [Signature("E8 ?? ?? ?? ?? F3 0F 10 35 ?? ?? ?? ?? 48 89 43 08")] + private readonly delegate* unmanaged _staticVfxCreate; + + [Signature("E8 ?? ?? ?? ?? 8B 4B 7C 85 C9")] + private readonly delegate* unmanaged _staticVfxRun; + + [Signature("40 53 48 83 EC 20 48 8B D9 48 8B 89 ?? ?? ?? ?? 48 85 C9 74 28 33 D2 E8 ?? ?? ?? ?? 48 8B 8B ?? ?? ?? ?? 48 85 C9")] + private readonly delegate* unmanaged _staticVfxRemove; + + public VfxSpawnManager(ILogger logger, IGameInteropProvider gameInteropProvider, MareMediator mareMediator) + : base(logger, mareMediator) + { + gameInteropProvider.InitializeFromAttributes(this); + mareMediator.Subscribe(this, (msg) => + { + ChangeSpawnVisibility(0f); + }); + mareMediator.Subscribe(this, (msg) => + { + ChangeSpawnVisibility(0.5f); + }); + mareMediator.Subscribe(this, (msg) => + { + ChangeSpawnVisibility(0f); + }); + mareMediator.Subscribe(this, (msg) => + { + ChangeSpawnVisibility(0.5f); + }); + } + + private unsafe void ChangeSpawnVisibility(float visibility) + { + foreach (var vfx in _spawnedObjects) + { + ((VfxStruct*)vfx.Value)->Alpha = visibility; + } + } + + private readonly Dictionary _spawnedObjects = []; + + private VfxStruct* SpawnStatic(string path, Vector3 pos, Quaternion rotation, float r, float g, float b, float a, Vector3 scale) + { + VfxStruct* vfx; + fixed (byte* terminatedPath = Encoding.UTF8.GetBytes(path).NullTerminate()) + { + fixed (byte* pool = _pool) + { + vfx = _staticVfxCreate(terminatedPath, pool); + } + } + + if (vfx == null) + { + return null; + } + + vfx->Position = new Vector3(pos.X, pos.Y + 1, pos.Z); + vfx->Rotation = new Quaternion(rotation.X, rotation.Y, rotation.Z, rotation.W); + + vfx->SomeFlags &= 0xF7; + vfx->Flags |= 2; + vfx->Red = r; + vfx->Green = g; + vfx->Blue = b; + vfx->Scale = scale; + + vfx->Alpha = a; + + _staticVfxRun(vfx, 0.0f, -1); + + return vfx; + } + + public Guid? SpawnObject(Vector3 position, Quaternion rotation, Vector3 scale, float r = 1f, float g = 1f, float b = 1f, float a = 0.5f) + { + Logger.LogDebug("Trying to Spawn orb VFX at {pos}, {rot}", position, rotation); + var vfx = SpawnStatic("bgcommon/world/common/vfx_for_event/eff/b0150_eext_y.avfx", position, rotation, r, g, b, a, scale); + if (vfx == null || (nint)vfx == nint.Zero) + { + Logger.LogDebug("Failed to Spawn VFX at {pos}, {rot}", position, rotation); + return null; + } + Guid guid = Guid.NewGuid(); + Logger.LogDebug("Spawned VFX at {pos}, {rot}: 0x{ptr:X}", position, rotation, (nint)vfx); + + _spawnedObjects[guid] = (nint)vfx; + + return guid; + } + + public void DespawnObject(Guid id) + { + if (_spawnedObjects.Remove(id, out var vfx)) + { + Logger.LogDebug("Despawning {obj:X}", vfx); + _staticVfxRemove((VfxStruct*)vfx); + } + } + + private void RemoveAllVfx() + { + foreach (var obj in _spawnedObjects.Values) + { + Logger.LogDebug("Despawning {obj:X}", obj); + _staticVfxRemove((VfxStruct*)obj); + } + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (disposing) + { + RemoveAllVfx(); + } + } + + [StructLayout(LayoutKind.Explicit)] + internal struct VfxStruct + { + [FieldOffset(0x38)] + public byte Flags; + + [FieldOffset(0x50)] + public Vector3 Position; + + [FieldOffset(0x60)] + public Quaternion Rotation; + + [FieldOffset(0x70)] + public Vector3 Scale; + + [FieldOffset(0x128)] + public int ActorCaster; + + [FieldOffset(0x130)] + public int ActorTarget; + + [FieldOffset(0x1B8)] + public int StaticCaster; + + [FieldOffset(0x1C0)] + public int StaticTarget; + + [FieldOffset(0x248)] + public byte SomeFlags; + + [FieldOffset(0x260)] + public float Red; + + [FieldOffset(0x264)] + public float Green; + + [FieldOffset(0x268)] + public float Blue; + + [FieldOffset(0x26C)] + public float Alpha; + } +} diff --git a/MareSynchronos/MareConfiguration/CharaDataConfigService.cs b/MareSynchronos/MareConfiguration/CharaDataConfigService.cs new file mode 100644 index 0000000..c0f4f15 --- /dev/null +++ b/MareSynchronos/MareConfiguration/CharaDataConfigService.cs @@ -0,0 +1,11 @@ +using MareSynchronos.MareConfiguration.Configurations; + +namespace MareSynchronos.MareConfiguration; + +public class CharaDataConfigService : ConfigurationServiceBase +{ + public const string ConfigName = "charadata.json"; + + public CharaDataConfigService(string configDir) : base(configDir) { } + public override string ConfigurationName => ConfigName; +} \ No newline at end of file diff --git a/MareSynchronos/MareConfiguration/Configurations/CharaDataConfig.cs b/MareSynchronos/MareConfiguration/Configurations/CharaDataConfig.cs new file mode 100644 index 0000000..6a99c1d --- /dev/null +++ b/MareSynchronos/MareConfiguration/Configurations/CharaDataConfig.cs @@ -0,0 +1,18 @@ +using MareSynchronos.MareConfiguration.Models; + +namespace MareSynchronos.MareConfiguration.Configurations; + +public class CharaDataConfig : IMareConfiguration +{ + public bool OpenMareHubOnGposeStart { get; set; } = false; + public string LastSavedCharaDataLocation { get; set; } = string.Empty; + public Dictionary FavoriteCodes { get; set; } = []; + public bool DownloadMcdDataOnConnection { get; set; } = true; + public int Version { get; set; } = 0; + public bool NearbyOwnServerOnly { get; set; } = false; + public bool NearbyIgnoreHousingLimitations { get; set; } = false; + public bool NearbyDrawWisps { get; set; } = true; + public int NearbyDistanceFilter { get; set; } = 100; + public bool NearbyShowOwnData { get; set; } = false; + public bool ShowHelpTexts { get; set; } = true; +} \ No newline at end of file diff --git a/MareSynchronos/MareConfiguration/Configurations/UidNotesConfig.cs b/MareSynchronos/MareConfiguration/Configurations/UidNotesConfig.cs index 7924c12..4941f00 100644 --- a/MareSynchronos/MareConfiguration/Configurations/UidNotesConfig.cs +++ b/MareSynchronos/MareConfiguration/Configurations/UidNotesConfig.cs @@ -7,4 +7,4 @@ public class UidNotesConfig : IMareConfiguration { public Dictionary ServerNotes { get; set; } = new(StringComparer.Ordinal); public int Version { get; set; } = 0; -} \ No newline at end of file +} diff --git a/MareSynchronos/MareConfiguration/Models/CharaDataFavorite.cs b/MareSynchronos/MareConfiguration/Models/CharaDataFavorite.cs new file mode 100644 index 0000000..29a0393 --- /dev/null +++ b/MareSynchronos/MareConfiguration/Models/CharaDataFavorite.cs @@ -0,0 +1,8 @@ +namespace MareSynchronos.MareConfiguration.Models; + +[Serializable] +public class CharaDataFavorite +{ + public DateTime LastDownloaded { get; set; } = DateTime.MaxValue; + public string CustomDescription { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/MareSynchronos/MareConfiguration/Models/ServerTagStorage.cs b/MareSynchronos/MareConfiguration/Models/ServerTagStorage.cs index eec5ac8..d7f7e7d 100644 --- a/MareSynchronos/MareConfiguration/Models/ServerTagStorage.cs +++ b/MareSynchronos/MareConfiguration/Models/ServerTagStorage.cs @@ -6,4 +6,4 @@ public class ServerTagStorage public HashSet OpenPairTags { get; set; } = new(StringComparer.Ordinal); public HashSet ServerAvailablePairTags { get; set; } = new(StringComparer.Ordinal); public Dictionary> UidServerPairedUserTags { get; set; } = new(StringComparer.Ordinal); -} \ No newline at end of file +} diff --git a/MareSynchronos/MareConfiguration/XivDataStorageService.cs b/MareSynchronos/MareConfiguration/XivDataStorageService.cs index 4cddfd0..777f728 100644 --- a/MareSynchronos/MareConfiguration/XivDataStorageService.cs +++ b/MareSynchronos/MareConfiguration/XivDataStorageService.cs @@ -9,4 +9,4 @@ public class XivDataStorageService : ConfigurationServiceBase ConfigName; -} \ No newline at end of file +} diff --git a/MareSynchronos/MareSynchronos.csproj b/MareSynchronos/MareSynchronos.csproj index aa5a769..630f16f 100644 --- a/MareSynchronos/MareSynchronos.csproj +++ b/MareSynchronos/MareSynchronos.csproj @@ -69,4 +69,8 @@ + + + + diff --git a/MareSynchronos/PlayerData/Export/MareCharaFileManager.cs b/MareSynchronos/PlayerData/Export/MareCharaFileManager.cs deleted file mode 100644 index 0eed3c0..0000000 --- a/MareSynchronos/PlayerData/Export/MareCharaFileManager.cs +++ /dev/null @@ -1,267 +0,0 @@ -using Dalamud.Game.ClientState.Objects.Types; -using K4os.Compression.LZ4.Legacy; -using MareSynchronos.API.Data.Enum; -using MareSynchronos.FileCache; -using MareSynchronos.Interop.Ipc; -using MareSynchronos.MareConfiguration; -using MareSynchronos.PlayerData.Factories; -using MareSynchronos.PlayerData.Handlers; -using MareSynchronos.Services; -using MareSynchronos.Services.Mediator; -using MareSynchronos.Utils; -using Microsoft.Extensions.Logging; -using System.Text; -using CharacterData = MareSynchronos.API.Data.CharacterData; - -namespace MareSynchronos.PlayerData.Export; - -public class MareCharaFileManager : DisposableMediatorSubscriberBase -{ - private readonly MareConfigService _configService; - private readonly DalamudUtilService _dalamudUtil; - private readonly MareCharaFileDataFactory _factory; - private readonly GameObjectHandlerFactory _gameObjectHandlerFactory; - private readonly Dictionary _gposeGameObjects; - private readonly List _gposeCustomizeObjects; - private readonly IpcManager _ipcManager; - private readonly ILogger _logger; - private readonly FileCacheManager _manager; - private int _globalFileCounter = 0; - private bool _isInGpose = false; - - public MareCharaFileManager(ILogger logger, GameObjectHandlerFactory gameObjectHandlerFactory, - FileCacheManager manager, IpcManager ipcManager, MareConfigService configService, DalamudUtilService dalamudUtil, - MareMediator mediator) : base(logger, mediator) - { - _factory = new(manager); - _logger = logger; - _gameObjectHandlerFactory = gameObjectHandlerFactory; - _manager = manager; - _ipcManager = ipcManager; - _configService = configService; - _dalamudUtil = dalamudUtil; - _gposeGameObjects = []; - _gposeCustomizeObjects = []; - Mediator.Subscribe(this, _ => _isInGpose = true); - Mediator.Subscribe(this, async _ => - { - _isInGpose = false; - CancellationTokenSource cts = new(); - foreach (var item in _gposeGameObjects) - { - if ((await dalamudUtil.RunOnFrameworkThread(() => item.Value.CurrentAddress()).ConfigureAwait(false)) != nint.Zero) - { - await _ipcManager.Glamourer.RevertAsync(logger, item.Value, Guid.NewGuid(), cts.Token).ConfigureAwait(false); - } - else - { - _logger.LogDebug("Reverting by name: {name}", item.Key); - _ipcManager.Glamourer.RevertByName(logger, item.Key, Guid.NewGuid()); - } - - - item.Value.Dispose(); - } - foreach (var id in _gposeCustomizeObjects.Where(d => d != null)) - { - await _ipcManager.CustomizePlus.RevertByIdAsync(id.Value); - } - _gposeGameObjects.Clear(); - }); - } - - public bool CurrentlyWorking { get; private set; } = false; - public MareCharaFileHeader? LoadedCharaFile { get; private set; } - - public async Task ApplyMareCharaFile(IGameObject? charaTarget, long expectedLength) - { - if (charaTarget == null) return; - Dictionary extractedFiles = new(StringComparer.Ordinal); - CurrentlyWorking = true; - try - { - if (LoadedCharaFile == null || !File.Exists(LoadedCharaFile.FilePath)) return; - var unwrapped = File.OpenRead(LoadedCharaFile.FilePath); - await using (unwrapped.ConfigureAwait(false)) - { - CancellationTokenSource disposeCts = new(); - using var lz4Stream = new LZ4Stream(unwrapped, LZ4StreamMode.Decompress, LZ4StreamFlags.HighCompression); - using var reader = new BinaryReader(lz4Stream); - MareCharaFileHeader.AdvanceReaderToData(reader); - _logger.LogDebug("Applying to {chara}, expected length of contents: {exp}, stream length: {len}", charaTarget.Name.TextValue, expectedLength, reader.BaseStream.Length); - extractedFiles = ExtractFilesFromCharaFile(LoadedCharaFile, reader, expectedLength); - Dictionary fileSwaps = new(StringComparer.Ordinal); - foreach (var fileSwap in LoadedCharaFile.CharaFileData.FileSwaps) - { - foreach (var path in fileSwap.GamePaths) - { - fileSwaps.Add(path, fileSwap.FileSwapPath); - } - } - var applicationId = Guid.NewGuid(); - var coll = await _ipcManager.Penumbra.CreateTemporaryCollectionAsync(_logger, charaTarget.Name.TextValue).ConfigureAwait(false); - await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(_logger, coll, charaTarget.ObjectIndex).ConfigureAwait(false); - await _ipcManager.Penumbra.SetTemporaryModsAsync(_logger, applicationId, coll, extractedFiles.Union(fileSwaps).ToDictionary(d => d.Key, d => d.Value, StringComparer.Ordinal)).ConfigureAwait(false); - await _ipcManager.Penumbra.SetManipulationDataAsync(_logger, applicationId, coll, LoadedCharaFile.CharaFileData.ManipulationData).ConfigureAwait(false); - - GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Player, - () => _dalamudUtil.GetGposeCharacterFromObjectTableByName(charaTarget.Name.ToString(), _isInGpose)?.Address ?? IntPtr.Zero, isWatched: false).ConfigureAwait(false); - - if (!_gposeGameObjects.ContainsKey(charaTarget.Name.ToString())) - _gposeGameObjects[charaTarget.Name.ToString()] = tempHandler; - - await _ipcManager.Glamourer.ApplyAllAsync(_logger, tempHandler, LoadedCharaFile.CharaFileData.GlamourerData, applicationId, disposeCts.Token).ConfigureAwait(false); - await _ipcManager.Penumbra.RedrawAsync(_logger, tempHandler, applicationId, disposeCts.Token).ConfigureAwait(false); - _dalamudUtil.WaitWhileGposeCharacterIsDrawing(charaTarget.Address, 30000); - await _ipcManager.Penumbra.RemoveTemporaryCollectionAsync(_logger, applicationId, coll).ConfigureAwait(false); - if (!string.IsNullOrEmpty(LoadedCharaFile.CharaFileData.CustomizePlusData)) - { - var id = await _ipcManager.CustomizePlus.SetBodyScaleAsync(tempHandler.Address, LoadedCharaFile.CharaFileData.CustomizePlusData).ConfigureAwait(false); - _gposeCustomizeObjects.Add(id); - } - else - { - var id = await _ipcManager.CustomizePlus.SetBodyScaleAsync(tempHandler.Address, Convert.ToBase64String(Encoding.UTF8.GetBytes("{}"))).ConfigureAwait(false); - _gposeCustomizeObjects.Add(id); - } - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failure to read MCDF"); - throw; - } - finally - { - CurrentlyWorking = false; - - _logger.LogDebug("Clearing local files"); - foreach (var file in Directory.EnumerateFiles(_configService.Current.CacheFolder, "*.tmp")) - { - File.Delete(file); - } - } - } - - public void ClearMareCharaFile() - { - LoadedCharaFile = null; - } - - public long LoadMareCharaFile(string filePath) - { - CurrentlyWorking = true; - try - { - using var unwrapped = File.OpenRead(filePath); - using var lz4Stream = new LZ4Stream(unwrapped, LZ4StreamMode.Decompress, LZ4StreamFlags.HighCompression); - using var reader = new BinaryReader(lz4Stream); - 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()); - } - return expectedLength; - } - finally { CurrentlyWorking = false; } - } - - public void SaveMareCharaFile(CharacterData? dto, string description, string filePath) - { - CurrentlyWorking = true; - var tempFilePath = filePath + ".tmp"; - - try - { - if (dto == null) return; - - var mareCharaFileData = _factory.Create(description, dto); - 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 = _manager.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); - } - using var fsRead = File.OpenRead(file.ResolvedFilepath); - using var br = new BinaryReader(fsRead); - byte[] buffer = new byte[item.Length]; - br.Read(buffer, 0, item.Length); - writer.Write(buffer); - } - writer.Flush(); - lz4.Flush(); - fs.Flush(); - fs.Close(); - File.Move(tempFilePath, filePath, true); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failure Saving Mare Chara File, deleting output"); - File.Delete(tempFilePath); - } - finally { CurrentlyWorking = false; } - } - - private Dictionary ExtractFilesFromCharaFile(MareCharaFileHeader charaFileHeader, BinaryReader reader, long expectedLength) - { - long totalRead = 0; - Dictionary gamePathToFilePath = new(StringComparer.Ordinal); - foreach (var fileData in charaFileHeader.CharaFileData.Files) - { - var fileName = Path.Combine(_configService.Current.CacheFolder, "mare_" + _globalFileCounter++ + ".tmp"); - 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; - } -} \ No newline at end of file diff --git a/MareSynchronos/PlayerData/Services/CacheCreationService.cs b/MareSynchronos/PlayerData/Services/CacheCreationService.cs index ca93dc8..dcb3406 100644 --- a/MareSynchronos/PlayerData/Services/CacheCreationService.cs +++ b/MareSynchronos/PlayerData/Services/CacheCreationService.cs @@ -23,6 +23,7 @@ public sealed class CacheCreationService : DisposableMediatorSubscriberBase private CancellationTokenSource _petNicknamesCts = new(); private CancellationTokenSource _moodlesCts = new(); private bool _isZoning = false; + private bool _haltCharaDataCreation; private readonly Dictionary _glamourerCts = new(); public CacheCreationService(ILogger logger, MareMediator mediator, GameObjectHandlerFactory gameObjectHandlerFactory, @@ -41,6 +42,11 @@ public sealed class CacheCreationService : DisposableMediatorSubscriberBase Mediator.Subscribe(this, (msg) => _isZoning = true); Mediator.Subscribe(this, (msg) => _isZoning = false); + Mediator.Subscribe(this, (msg) => + { + _haltCharaDataCreation = !msg.Resume; + }); + _playerRelatedObjects[ObjectKind.Player] = gameObjectHandlerFactory.Create(ObjectKind.Player, dalamudUtil.GetPlayerPointer, isWatched: true) .GetAwaiter().GetResult(); _playerRelatedObjects[ObjectKind.MinionOrMount] = gameObjectHandlerFactory.Create(ObjectKind.MinionOrMount, () => dalamudUtil.GetMinionOrMount(), isWatched: true) @@ -218,7 +224,7 @@ public sealed class CacheCreationService : DisposableMediatorSubscriberBase private void ProcessCacheCreation() { - if (_isZoning) return; + if (_isZoning || _haltCharaDataCreation) return; if (_cachesToCreate.Any() && (_cacheCreationTask?.IsCompleted ?? true)) { diff --git a/MareSynchronos/Plugin.cs b/MareSynchronos/Plugin.cs index f3a9709..3048c4d 100644 --- a/MareSynchronos/Plugin.cs +++ b/MareSynchronos/Plugin.cs @@ -8,7 +8,6 @@ using MareSynchronos.Interop; using MareSynchronos.Interop.Ipc; using MareSynchronos.MareConfiguration; using MareSynchronos.MareConfiguration.Configurations; -using MareSynchronos.PlayerData.Export; using MareSynchronos.PlayerData.Factories; using MareSynchronos.PlayerData.Pairs; using MareSynchronos.PlayerData.Services; @@ -93,7 +92,6 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); - collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); @@ -112,10 +110,17 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); + + collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(); + + collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); @@ -127,8 +132,9 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); - collection.AddSingleton(); collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); @@ -141,6 +147,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton((s) => new XivDataStorageService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton((s) => new PlayerPerformanceConfigService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton((s) => new ServerBlockConfigService(pluginInterface.ConfigDirectory.FullName)); + collection.AddSingleton((s) => new CharaDataConfigService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton>(s => s.GetRequiredService()); collection.AddSingleton>(s => s.GetRequiredService()); collection.AddSingleton>(s => s.GetRequiredService()); @@ -150,6 +157,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton>(s => s.GetRequiredService()); collection.AddSingleton>(s => s.GetRequiredService()); collection.AddSingleton>(s => s.GetRequiredService()); + collection.AddSingleton>(s => s.GetRequiredService()); collection.AddSingleton(); collection.AddSingleton(); @@ -160,12 +168,12 @@ public sealed class Plugin : IDalamudPlugin collection.AddScoped(); collection.AddScoped(); collection.AddScoped(); - collection.AddScoped(); collection.AddScoped(); collection.AddScoped(); collection.AddScoped(); collection.AddScoped(); collection.AddScoped(); + collection.AddScoped(); collection.AddScoped(); collection.AddScoped(); collection.AddScoped(); diff --git a/MareSynchronos/Services/CharaData/CharaDataCharacterHandler.cs b/MareSynchronos/Services/CharaData/CharaDataCharacterHandler.cs new file mode 100644 index 0000000..aa2b9f0 --- /dev/null +++ b/MareSynchronos/Services/CharaData/CharaDataCharacterHandler.cs @@ -0,0 +1,141 @@ +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; + +internal sealed class CharaDataCharacterHandler : DisposableMediatorSubscriberBase +{ + private readonly GameObjectHandlerFactory _gameObjectHandlerFactory; + private readonly DalamudUtilService _dalamudUtilService; + private readonly IpcManager _ipcManager; + private readonly HashSet _handledCharaData = []; + + public IEnumerable HandledCharaData => _handledCharaData; + + public CharaDataCharacterHandler(ILogger logger, MareMediator mediator, + GameObjectHandlerFactory gameObjectHandlerFactory, DalamudUtilService dalamudUtilService, + IpcManager ipcManager) + : base(logger, mediator) + { + _gameObjectHandlerFactory = gameObjectHandlerFactory; + _dalamudUtilService = dalamudUtilService; + _ipcManager = ipcManager; + mediator.Subscribe(this, (_) => + { + foreach (var chara in _handledCharaData) + { + RevertHandledChara(chara, false); + } + }); + + mediator.Subscribe(this, (_) => HandleCutsceneFrameworkUpdate()); + } + + private void HandleCutsceneFrameworkUpdate() + { + if (!_dalamudUtilService.IsInGpose) return; + + foreach (var entry in _handledCharaData.ToList()) + { + var chara = _dalamudUtilService.GetGposeCharacterFromObjectTableByName(entry.Name, onlyGposeCharacters: true); + if (chara is null) + { + RevertChara(entry.Name, entry.CustomizePlus).GetAwaiter().GetResult(); + _handledCharaData.Remove(entry); + } + } + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + foreach (var chara in _handledCharaData) + { + RevertHandledChara(chara, false); + } + } + + public async Task RevertChara(string name, Guid? cPlusId, bool reapplyPose = true) + { + 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 != IntPtr.Zero) + { + var poseData = string.Empty; + API.Dto.CharaData.WorldData? worldData = null; + if (_dalamudUtilService.IsInGpose && reapplyPose) + { + poseData = await _ipcManager.Brio.GetPoseAsync(handler.Address).ConfigureAwait(false); + worldData = await _ipcManager.Brio.GetTransformAsync(handler.Address).ConfigureAwait(false); + } + await _ipcManager.Penumbra.RedrawAsync(Logger, handler, applicationId, CancellationToken.None).ConfigureAwait(false); + if (_dalamudUtilService.IsInGpose && reapplyPose) + { + await _ipcManager.Brio.SetPoseAsync(handler.Address, poseData ?? "{}").ConfigureAwait(false); + await _ipcManager.Brio.ApplyTransformAsync(handler.Address, worldData!.Value).ConfigureAwait(false); + } + } + } + + public async Task RevertHandledChara(string name, bool reapplyPose = true) + { + var handled = _handledCharaData.FirstOrDefault(f => string.Equals(f.Name, name, StringComparison.Ordinal)); + if (handled == null) return false; + _handledCharaData.Remove(handled); + await _dalamudUtilService.RunOnFrameworkThread(() => RevertChara(handled.Name, handled.CustomizePlus, reapplyPose)).ConfigureAwait(false); + return true; + } + + public Task RevertHandledChara(HandledCharaDataEntry? handled, bool reapplyPose = true) + { + if (handled == null) return Task.CompletedTask; + _handledCharaData.Remove(handled); + return _dalamudUtilService.RunOnFrameworkThread(() => RevertChara(handled.Name, handled.CustomizePlus, reapplyPose)); + } + + internal void AddHandledChara(HandledCharaDataEntry handledCharaDataEntry) + { + _handledCharaData.Add(handledCharaDataEntry); + } + + public void UpdateHandledData(Dictionary newData) + { + foreach (var handledData in _handledCharaData) + { + if (newData.TryGetValue(handledData.MetaInfo.FullId, out var metaInfo) && metaInfo != null) + { + handledData.MetaInfo = metaInfo; + } + } + } + + public async Task 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 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; + } +} diff --git a/MareSynchronos/Services/CharaData/CharaDataFileHandler.cs b/MareSynchronos/Services/CharaData/CharaDataFileHandler.cs new file mode 100644 index 0000000..6ba6bd5 --- /dev/null +++ b/MareSynchronos/Services/CharaData/CharaDataFileHandler.cs @@ -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; + +internal 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 _logger; + private readonly MareCharaFileDataFactory _mareCharaFileDataFactory; + private readonly PlayerDataFactory _playerDataFactory; + private int _globalFileCounter = 0; + + public CharaDataFileHandler(ILogger 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 modPaths, out List 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 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 missingFiles, Dictionary 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 McdfExtractFiles(MareCharaFileHeader? charaFileHeader, long expectedLength, List 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 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> UploadFiles(List fileList, ValueProgress uploadProgress, CancellationToken token) + { + return await _fileUploadManager.UploadFiles(fileList, uploadProgress, token).ConfigureAwait(false); + } +} diff --git a/MareSynchronos/Services/CharaData/CharaDataManager.cs b/MareSynchronos/Services/CharaData/CharaDataManager.cs new file mode 100644 index 0000000..0b492be --- /dev/null +++ b/MareSynchronos/Services/CharaData/CharaDataManager.cs @@ -0,0 +1,937 @@ +using Dalamud.Game.ClientState.Objects.Types; +using K4os.Compression.LZ4.Legacy; +using MareSynchronos.API.Data; +using MareSynchronos.API.Dto.CharaData; +using MareSynchronos.Interop.Ipc; +using MareSynchronos.MareConfiguration; +using MareSynchronos.PlayerData.Factories; +using MareSynchronos.PlayerData.Handlers; +using MareSynchronos.Services.CharaData.Models; +using MareSynchronos.Services.Mediator; +using MareSynchronos.Utils; +using MareSynchronos.WebAPI; +using Microsoft.Extensions.Logging; +using System.Text; + +namespace MareSynchronos.Services; + +internal sealed partial class CharaDataManager : DisposableMediatorSubscriberBase +{ + private readonly ApiController _apiController; + private readonly CharaDataConfigService _configService; + private readonly DalamudUtilService _dalamudUtilService; + private readonly CharaDataFileHandler _fileHandler; + private readonly IpcManager _ipcManager; + private readonly Dictionary _metaInfoCache = []; + private readonly List _nearbyData = []; + private readonly CharaDataNearbyManager _nearbyManager; + private readonly CharaDataCharacterHandler _characterHandler; + private readonly Dictionary _ownCharaData = []; + private readonly Dictionary _sharedMetaInfoTimeoutTasks = []; + private readonly Dictionary> _sharedWithYouData = []; + private readonly Dictionary _updateDtos = []; + private CancellationTokenSource _applicationCts = new(); + private CancellationTokenSource _charaDataCreateCts = new(); + private CancellationTokenSource _connectCts = new(); + private CancellationTokenSource _getAllDataCts = new(); + private CancellationTokenSource _getSharedDataCts = new(); + private CancellationTokenSource _uploadCts = new(); + + public CharaDataManager(ILogger logger, ApiController apiController, + CharaDataFileHandler charaDataFileHandler, + MareMediator mareMediator, IpcManager ipcManager, DalamudUtilService dalamudUtilService, + FileDownloadManagerFactory fileDownloadManagerFactory, + CharaDataConfigService charaDataConfigService, CharaDataNearbyManager charaDataNearbyManager, + CharaDataCharacterHandler charaDataCharacterHandler) : base(logger, mareMediator) + { + _apiController = apiController; + _fileHandler = charaDataFileHandler; + _ipcManager = ipcManager; + _dalamudUtilService = dalamudUtilService; + _configService = charaDataConfigService; + _nearbyManager = charaDataNearbyManager; + _characterHandler = charaDataCharacterHandler; + mareMediator.Subscribe(this, (msg) => + { + _connectCts?.Cancel(); + _connectCts?.Dispose(); + _connectCts = new(); + _ownCharaData.Clear(); + _metaInfoCache.Clear(); + _sharedWithYouData.Clear(); + _updateDtos.Clear(); + Initialized = false; + MaxCreatableCharaData = msg.Connection.ServerInfo.MaxCharaData; + if (_configService.Current.DownloadMcdDataOnConnection) + { + var token = _connectCts.Token; + _ = GetAllData(token); + _ = GetAllSharedData(token); + } + }); + mareMediator.Subscribe(this, (msg) => + { + _ownCharaData.Clear(); + _metaInfoCache.Clear(); + _sharedWithYouData.Clear(); + _updateDtos.Clear(); + Initialized = false; + }); + } + + public Task? AttachingPoseTask { get; private set; } + public Task? CharaUpdateTask { get; set; } + public string DataApplicationProgress { get; private set; } = string.Empty; + public Task? DataApplicationTask { get; private set; } + public Task<(string Output, bool Success)>? DataCreationTask { get; private set; } + public Task? DataGetTimeoutTask { get; private set; } + public Task<(string Result, bool Success)>? DownloadMetaInfoTask { get; private set; } + public Task>? GetAllDataTask { get; private set; } + public Task>? GetSharedWithYouTask { get; private set; } + public Task? GetSharedWithYouTimeoutTask { get; private set; } + public IEnumerable HandledCharaData => _characterHandler.HandledCharaData; + public bool Initialized { get; private set; } + public CharaDataMetaInfoExtendedDto? LastDownloadedMetaInfo { get; private set; } + public Task<(MareCharaFileHeader LoadedFile, long ExpectedLength)>? LoadedMcdfHeader { get; private set; } + public int MaxCreatableCharaData { get; private set; } + public Task? McdfApplicationTask { get; private set; } + public List NearbyData => _nearbyData; + public IDictionary OwnCharaData => _ownCharaData; + public IDictionary> SharedWithYouData => _sharedWithYouData; + public Task? UiBlockingComputation { get; private set; } + public ValueProgress? UploadProgress { get; private set; } + public Task<(string Output, bool Success)>? UploadTask { get; private set; } + public bool BrioAvailable => _ipcManager.Brio.APIAvailable; + + public Task ApplyCharaData(CharaDataMetaInfoDto dataMetaInfoDto, string charaName) + { + return UiBlockingComputation = DataApplicationTask = Task.Run(async () => + { + if (string.IsNullOrEmpty(charaName)) return; + + var download = await _apiController.CharaDataDownload(dataMetaInfoDto.Uploader.UID + ":" + dataMetaInfoDto.Id).ConfigureAwait(false); + if (download == null) + { + DataApplicationTask = null; + return; + } + + await DownloadAndAplyDataAsync(charaName, download, dataMetaInfoDto, false).ConfigureAwait(false); + }); + } + + public Task ApplyCharaDataToGposeTarget(CharaDataMetaInfoDto dataMetaInfoDto) + { + return UiBlockingComputation = DataApplicationTask = Task.Run(async () => + { + var charaName = await _dalamudUtilService.RunOnFrameworkThread(() => _dalamudUtilService.GposeTargetGameObject?.Name.TextValue).ConfigureAwait(false) + ?? string.Empty; + if (string.IsNullOrEmpty(charaName)) return; + + await ApplyCharaData(dataMetaInfoDto, charaName).ConfigureAwait(false); + }); + } + + public void ApplyOwnDataToGposeTarget(CharaDataFullExtendedDto dataDto) + { + var charaName = _dalamudUtilService.GposeTargetGameObject?.Name.TextValue ?? string.Empty; + CharaDataDownloadDto downloadDto = new(dataDto.Id, dataDto.Uploader) + { + CustomizeData = dataDto.CustomizeData, + Description = dataDto.Description, + FileGamePaths = dataDto.FileGamePaths, + GlamourerData = dataDto.GlamourerData, + FileSwaps = dataDto.FileSwaps, + ManipulationData = dataDto.ManipulationData, + UpdatedDate = dataDto.UpdatedDate + }; + + CharaDataMetaInfoDto metaInfoDto = new(dataDto.Id, dataDto.Uploader) + { + CanBeDownloaded = true, + Description = dataDto.Description, + PoseData = dataDto.PoseData, + UpdatedDate = dataDto.UpdatedDate, + }; + + UiBlockingComputation = DataApplicationTask = DownloadAndAplyDataAsync(charaName, downloadDto, metaInfoDto, false); + } + + public Task ApplyPoseData(PoseEntry pose, string targetName) + { + return UiBlockingComputation = Task.Run(async () => + { + if (string.IsNullOrEmpty(pose.PoseData) || !CanApplyInGpose(out _)) return; + var gposeChara = await _dalamudUtilService.GetGposeCharacterFromObjectTableByNameAsync(targetName, true).ConfigureAwait(false); + if (gposeChara == null) return; + + var poseJson = Encoding.UTF8.GetString(LZ4Wrapper.Unwrap(Convert.FromBase64String(pose.PoseData))); + if (string.IsNullOrEmpty(poseJson)) return; + + await _ipcManager.Brio.SetPoseAsync(gposeChara.Address, poseJson).ConfigureAwait(false); + }); + } + + public Task ApplyPoseDataToGPoseTarget(PoseEntry pose) + { + return UiBlockingComputation = Task.Run(async () => + { + if (CanApplyInGpose(out var chara)) + { + await ApplyPoseData(pose, chara).ConfigureAwait(false); + } + }); + } + + public Task ApplyWorldDataToTarget(PoseEntry pose, string targetName) + { + return UiBlockingComputation = Task.Run(async () => + { + if (pose.WorldData == default || !CanApplyInGpose(out _)) return; + var gposeChara = await _dalamudUtilService.GetGposeCharacterFromObjectTableByNameAsync(targetName, true).ConfigureAwait(false); + if (gposeChara == null) return; + + if (pose.WorldData == null || pose.WorldData == default) return; + + Logger.LogDebug("Applying World data {data}", pose.WorldData); + + await _ipcManager.Brio.ApplyTransformAsync(gposeChara.Address, pose.WorldData.Value).ConfigureAwait(false); + }); + } + + public Task ApplyWorldDataToGPoseTarget(PoseEntry pose) + { + return UiBlockingComputation = Task.Run(async () => + { + if (CanApplyInGpose(out var chara)) + { + await ApplyPoseData(pose, chara).ConfigureAwait(false); + } + }); + } + + public void AttachWorldData(PoseEntry pose, CharaDataExtendedUpdateDto updateDto) + { + AttachingPoseTask = Task.Run(async () => + { + ICharacter? playerChar = await _dalamudUtilService.GetPlayerCharacterAsync().ConfigureAwait(false); + if (playerChar == null) return; + if (_dalamudUtilService.IsInGpose) + { + playerChar = await _dalamudUtilService.GetGposeCharacterFromObjectTableByNameAsync(playerChar.Name.TextValue, true).ConfigureAwait(false); + } + if (playerChar == null) return; + var worldData = await _ipcManager.Brio.GetTransformAsync(playerChar.Address).ConfigureAwait(false); + if (worldData == default) return; + + Logger.LogTrace("Attaching World data {data}", worldData); + + worldData.LocationInfo = await _dalamudUtilService.GetMapDataAsync().ConfigureAwait(false); + + Logger.LogTrace("World data serialized: {data}", worldData); + + pose.WorldData = worldData; + + updateDto.UpdatePoseList(); + }); + } + + public bool CanApplyInGpose(out string targetName) + { + bool canApply = _dalamudUtilService.IsInGpose && _dalamudUtilService.GposeTargetGameObject != null + && _dalamudUtilService.GposeTargetGameObject.ObjectKind == Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player; + if (canApply) + { + targetName = _dalamudUtilService.GposeTargetGameObject!.Name.TextValue; + } + else + { + targetName = "Invalid Target"; + } + return canApply; + } + + public void CancelDataApplication() + { + _applicationCts.Cancel(); + } + + public void CancelUpload() + { + _uploadCts.Cancel(); + } + + public void CreateCharaDataEntry(CancellationToken cancelToken) + { + UiBlockingComputation = DataCreationTask = Task.Run(async () => + { + var result = await _apiController.CharaDataCreate().ConfigureAwait(false); + _ = Task.Run(async () => + { + _charaDataCreateCts = _charaDataCreateCts.CancelRecreate(); + using var ct = CancellationTokenSource.CreateLinkedTokenSource(_charaDataCreateCts.Token, cancelToken); + await Task.Delay(TimeSpan.FromSeconds(10), ct.Token).ConfigureAwait(false); + DataCreationTask = null; + }); + + + if (result == null) + return ("Failed to create character data, see log for more information", false); + + await AddOrUpdateDto(result).ConfigureAwait(false); + + return ("Created Character Data", true); + }); + } + + public async Task DeleteCharaData(CharaDataFullExtendedDto dto) + { + var ret = await _apiController.CharaDataDelete(dto.Id).ConfigureAwait(false); + if (ret) + { + _ownCharaData.Remove(dto.Id); + _metaInfoCache.Remove(dto.FullId); + } + DistributeMetaInfo(); + } + + public void DownloadMetaInfo(string importCode, bool store = true) + { + DownloadMetaInfoTask = Task.Run(async () => + { + try + { + if (store) + { + LastDownloadedMetaInfo = null; + } + var metaInfo = await _apiController.CharaDataGetMetainfo(importCode).ConfigureAwait(false); + _sharedMetaInfoTimeoutTasks[importCode] = Task.Delay(TimeSpan.FromSeconds(10)); + if (metaInfo == null) + { + _metaInfoCache[importCode] = null; + return ("Failed to download meta info for this code. Check if the code is valid and you have rights to access it.", false); + } + await CacheData(metaInfo).ConfigureAwait(false); + if (store) + { + LastDownloadedMetaInfo = await CharaDataMetaInfoExtendedDto.Create(metaInfo, _dalamudUtilService).ConfigureAwait(false); + } + return ("Ok", true); + } + finally + { + if (!store) + DownloadMetaInfoTask = null; + } + }); + } + + public async Task GetAllData(CancellationToken cancelToken) + { + foreach (var data in _ownCharaData) + { + _metaInfoCache.Remove(data.Key); + } + _ownCharaData.Clear(); + UiBlockingComputation = GetAllDataTask = Task.Run(async () => + { + _getAllDataCts = _getAllDataCts.CancelRecreate(); + var result = await _apiController.CharaDataGetOwn().ConfigureAwait(false); + + Initialized = true; + + if (result.Any()) + { + DataGetTimeoutTask = Task.Run(async () => + { + using var ct = CancellationTokenSource.CreateLinkedTokenSource(_getAllDataCts.Token, cancelToken); +#if !DEBUG + await Task.Delay(TimeSpan.FromMinutes(1), ct.Token).ConfigureAwait(false); +#else + await Task.Delay(TimeSpan.FromSeconds(5), ct.Token).ConfigureAwait(false); +#endif + }); + } + + return result.OrderBy(u => u.CreatedDate).Select(k => new CharaDataFullExtendedDto(k)).ToList(); + }); + + var result = await GetAllDataTask.ConfigureAwait(false); + foreach (var item in result) + { + await AddOrUpdateDto(item).ConfigureAwait(false); + } + + foreach (var id in _updateDtos.Keys.Where(r => !result.Exists(res => string.Equals(res.Id, r, StringComparison.Ordinal))).ToList()) + { + _updateDtos.Remove(id); + } + GetAllDataTask = null; + } + + public async Task GetAllSharedData(CancellationToken token) + { + Logger.LogDebug("Getting Shared with You Data"); + + UiBlockingComputation = GetSharedWithYouTask = _apiController.CharaDataGetShared(); + _sharedWithYouData.Clear(); + + GetSharedWithYouTimeoutTask = Task.Run(async () => + { + _getSharedDataCts = _getSharedDataCts.CancelRecreate(); + using var ct = CancellationTokenSource.CreateLinkedTokenSource(_getSharedDataCts.Token, token); +#if !DEBUG + await Task.Delay(TimeSpan.FromMinutes(1), ct.Token).ConfigureAwait(false); +#else + await Task.Delay(TimeSpan.FromSeconds(5), ct.Token).ConfigureAwait(false); +#endif + GetSharedWithYouTimeoutTask = null; + Logger.LogDebug("Finished Shared with You Data Timeout"); + }); + + var result = await GetSharedWithYouTask.ConfigureAwait(false); + foreach (var grouping in result.GroupBy(r => r.Uploader)) + { + List newList = new(); + foreach (var item in grouping) + { + var extended = await CharaDataMetaInfoExtendedDto.Create(item, _dalamudUtilService).ConfigureAwait(false); + newList.Add(extended); + CacheData(extended); + } + _sharedWithYouData[grouping.Key] = newList; + } + + DistributeMetaInfo(); + + Logger.LogDebug("Finished getting Shared with You Data"); + GetSharedWithYouTask = null; + } + + public CharaDataExtendedUpdateDto? GetUpdateDto(string id) + { + if (_updateDtos.TryGetValue(id, out var dto)) + return dto; + return null; + } + + public bool IsInTimeout(string key) + { + if (!_sharedMetaInfoTimeoutTasks.TryGetValue(key, out var task)) return false; + return !task?.IsCompleted ?? false; + } + + public void LoadMcdf(string filePath) + { + LoadedMcdfHeader = _fileHandler.LoadCharaFileHeader(filePath); + } + + public void McdfApplyToGposeTarget() + { + if (LoadedMcdfHeader == null || !LoadedMcdfHeader.IsCompletedSuccessfully) return; + var charaName = _dalamudUtilService.GposeTargetGameObject?.Name.TextValue ?? string.Empty; + + List actuallyExtractedFiles = []; + UiBlockingComputation = McdfApplicationTask = Task.Run(async () => + { + Guid applicationId = Guid.NewGuid(); + try + { + using GameObjectHandler? tempHandler = await _characterHandler.TryCreateGameObjectHandler(charaName, true).ConfigureAwait(false); + if (tempHandler == null) return; + var playerChar = await _dalamudUtilService.GetPlayerCharacterAsync().ConfigureAwait(false); + bool isSelf = playerChar != null && string.Equals(playerChar.Name.TextValue, tempHandler.Name, StringComparison.Ordinal); + + long expectedExtractedSize = LoadedMcdfHeader.Result.ExpectedLength; + var charaFile = LoadedMcdfHeader.Result.LoadedFile; + DataApplicationProgress = "Extracting MCDF data"; + + var extractedFiles = _fileHandler.McdfExtractFiles(charaFile, expectedExtractedSize, actuallyExtractedFiles); + + foreach (var entry in charaFile.CharaFileData.FileSwaps.SelectMany(k => k.GamePaths, (k, p) => new KeyValuePair(p, k.FileSwapPath))) + { + extractedFiles[entry.Key] = entry.Value; + } + + DataApplicationProgress = "Applying MCDF data"; + + var extended = await CharaDataMetaInfoExtendedDto.Create(new(charaFile.FilePath, new UserData(string.Empty)), _dalamudUtilService) + .ConfigureAwait(false); + await ApplyDataAsync(applicationId, tempHandler, isSelf, autoRevert: false, extended, + extractedFiles, charaFile.CharaFileData.ManipulationData, charaFile.CharaFileData.GlamourerData, + charaFile.CharaFileData.CustomizePlusData, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to extract MCDF"); + throw; + } + finally + { + // delete extracted files + foreach (var file in actuallyExtractedFiles) + { + File.Delete(file); + } + } + }); + } + + public void SaveMareCharaFile(string description, string filePath) + { + UiBlockingComputation = Task.Run(async () => await _fileHandler.SaveCharaFileAsync(description, filePath).ConfigureAwait(false)); + } + + public void SetAppearanceData(string dtoId) + { + var hasDto = _ownCharaData.TryGetValue(dtoId, out var dto); + if (!hasDto || dto == null) return; + + var hasUpdateDto = _updateDtos.TryGetValue(dtoId, out var updateDto); + if (!hasUpdateDto || updateDto == null) return; + + UiBlockingComputation = Task.Run(async () => + { + await _fileHandler.UpdateCharaDataAsync(updateDto).ConfigureAwait(false); + }); + } + + public Task SpawnAndApplyData(CharaDataMetaInfoDto charaDataMetaInfoDto) + { + var task = Task.Run(async () => + { + var newActor = await _ipcManager.Brio.SpawnActorAsync().ConfigureAwait(false); + if (newActor == null) return null; + await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + + await ApplyCharaData(charaDataMetaInfoDto, newActor.Name.TextValue).ConfigureAwait(false); + + return _characterHandler.HandledCharaData.FirstOrDefault(f => string.Equals(f.Name, newActor.Name.TextValue, StringComparison.Ordinal)); + }); + UiBlockingComputation = task; + return task; + } + + private async Task CacheData(CharaDataFullExtendedDto ownCharaData) + { + var metaInfo = new CharaDataMetaInfoDto(ownCharaData.Id, ownCharaData.Uploader) + { + Description = ownCharaData.Description, + UpdatedDate = ownCharaData.UpdatedDate, + CanBeDownloaded = !string.IsNullOrEmpty(ownCharaData.GlamourerData) && (ownCharaData.OriginalFiles.Count == ownCharaData.FileGamePaths.Count), + PoseData = ownCharaData.PoseData, + }; + + var extended = await CharaDataMetaInfoExtendedDto.Create(metaInfo, _dalamudUtilService, isOwnData: true).ConfigureAwait(false); + _metaInfoCache[extended.FullId] = extended; + DistributeMetaInfo(); + + return extended; + } + + private async Task CacheData(CharaDataMetaInfoDto metaInfo, bool isOwnData = false) + { + var extended = await CharaDataMetaInfoExtendedDto.Create(metaInfo, _dalamudUtilService, isOwnData).ConfigureAwait(false); + _metaInfoCache[extended.FullId] = extended; + DistributeMetaInfo(); + + return extended; + } + + private void DistributeMetaInfo() + { + _nearbyManager.UpdateSharedData(_metaInfoCache); + _characterHandler.UpdateHandledData(_metaInfoCache); + } + + private void CacheData(CharaDataMetaInfoExtendedDto charaData) + { + _metaInfoCache[charaData.FullId] = charaData; + } + + public bool TryGetMetaInfo(string key, out CharaDataMetaInfoExtendedDto? metaInfo) + { + return _metaInfoCache.TryGetValue(key, out metaInfo); + } + + public void UploadCharaData(string id) + { + var hasUpdateDto = _updateDtos.TryGetValue(id, out var updateDto); + if (!hasUpdateDto || updateDto == null) return; + + UiBlockingComputation = CharaUpdateTask = CharaUpdateAsync(updateDto); + } + + public void UploadMissingFiles(string id) + { + var hasDto = _ownCharaData.TryGetValue(id, out var dto); + if (!hasDto || dto == null) return; + + var missingFileList = dto.MissingFiles.ToList(); + UiBlockingComputation = UploadTask = UploadFiles(missingFileList, async () => + { + var newFilePaths = dto.FileGamePaths; + foreach (var missing in missingFileList) + { + newFilePaths.Add(missing); + } + CharaDataUpdateDto updateDto = new(dto.Id) + { + FileGamePaths = newFilePaths + }; + var res = await _apiController.CharaDataUpdate(updateDto).ConfigureAwait(false); + await AddOrUpdateDto(res).ConfigureAwait(false); + }); + } + + internal void ApplyDataToSelf(CharaDataFullExtendedDto dataDto) + { + var chara = _dalamudUtilService.GetPlayerName(); + CharaDataDownloadDto downloadDto = new(dataDto.Id, dataDto.Uploader) + { + CustomizeData = dataDto.CustomizeData, + Description = dataDto.Description, + FileGamePaths = dataDto.FileGamePaths, + GlamourerData = dataDto.GlamourerData, + FileSwaps = dataDto.FileSwaps, + ManipulationData = dataDto.ManipulationData, + UpdatedDate = dataDto.UpdatedDate + }; + + CharaDataMetaInfoDto metaInfoDto = new(dataDto.Id, dataDto.Uploader) + { + CanBeDownloaded = true, + Description = dataDto.Description, + PoseData = dataDto.PoseData, + UpdatedDate = dataDto.UpdatedDate, + }; + + UiBlockingComputation = DataApplicationTask = DownloadAndAplyDataAsync(chara, downloadDto, metaInfoDto); + } + + internal void AttachPoseData(PoseEntry pose, CharaDataExtendedUpdateDto updateDto) + { + AttachingPoseTask = Task.Run(async () => + { + ICharacter? playerChar = await _dalamudUtilService.GetPlayerCharacterAsync().ConfigureAwait(false); + if (playerChar == null) return; + if (_dalamudUtilService.IsInGpose) + { + playerChar = await _dalamudUtilService.GetGposeCharacterFromObjectTableByNameAsync(playerChar.Name.TextValue, true).ConfigureAwait(false); + } + if (playerChar == null) return; + var poseData = await _ipcManager.Brio.GetPoseAsync(playerChar.Address).ConfigureAwait(false); + if (poseData == null) return; + + var compressedByteData = LZ4Wrapper.WrapHC(Encoding.UTF8.GetBytes(poseData)); + pose.PoseData = Convert.ToBase64String(compressedByteData); + updateDto.UpdatePoseList(); + }); + } + + internal void McdfSpawnApplyToGposeTarget() + { + UiBlockingComputation = Task.Run(async () => + { + var newActor = await _ipcManager.Brio.SpawnActorAsync().ConfigureAwait(false); + if (newActor == null) return; + await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + unsafe + { + _dalamudUtilService.GposeTarget = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)newActor.Address; + } + + McdfApplyToGposeTarget(); + }); + } + + internal void ApplyFullPoseDataToTarget(PoseEntry value, string targetName) + { + UiBlockingComputation = Task.Run(async () => + { + await ApplyPoseData(value, targetName).ConfigureAwait(false); + await ApplyWorldDataToTarget(value, targetName).ConfigureAwait(false); + }); + } + + internal void ApplyFullPoseDataToGposeTarget(PoseEntry value) + { + UiBlockingComputation = Task.Run(async () => + { + if (CanApplyInGpose(out var gposeTarget)) + { + await ApplyPoseData(value, gposeTarget).ConfigureAwait(false); + await ApplyWorldDataToTarget(value, gposeTarget).ConfigureAwait(false); + } + }); + } + + internal void SpawnAndApplyWorldTransform(CharaDataMetaInfoDto metaInfo, PoseEntry value) + { + UiBlockingComputation = Task.Run(async () => + { + var actor = await SpawnAndApplyData(metaInfo).ConfigureAwait(false); + if (actor == null) return; + await ApplyPoseData(value, actor.Name).ConfigureAwait(false); + await ApplyWorldDataToTarget(value, actor.Name).ConfigureAwait(false); + }); + } + + internal unsafe void TargetGposeActor(HandledCharaDataEntry actor) + { + var gposeActor = _dalamudUtilService.GetGposeCharacterFromObjectTableByName(actor.Name, true); + if (gposeActor != null) + { + _dalamudUtilService.GposeTarget = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)gposeActor.Address; + } + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (disposing) + { + _getAllDataCts?.Cancel(); + _getAllDataCts?.Dispose(); + _getSharedDataCts?.Cancel(); + _getSharedDataCts?.Dispose(); + _charaDataCreateCts?.Cancel(); + _charaDataCreateCts?.Dispose(); + _uploadCts?.Cancel(); + _uploadCts?.Dispose(); + _applicationCts.Cancel(); + _applicationCts.Dispose(); + _connectCts?.Cancel(); + _connectCts?.Dispose(); + } + } + + private async Task AddOrUpdateDto(CharaDataFullDto? dto) + { + if (dto == null) return; + + _ownCharaData[dto.Id] = new(dto); + _updateDtos[dto.Id] = new(new(dto.Id), _ownCharaData[dto.Id]); + + await CacheData(_ownCharaData[dto.Id]).ConfigureAwait(false); + } + + private async Task ApplyDataAsync(Guid applicationId, GameObjectHandler tempHandler, bool isSelf, bool autoRevert, + CharaDataMetaInfoExtendedDto metaInfo, Dictionary modPaths, string? manipData, string? glamourerData, string? customizeData, CancellationToken token) + { + Guid? cPlusId = null; + Guid penumbraCollection; + try + { + DataApplicationProgress = "Reverting previous Application"; + + Logger.LogTrace("[{appId}] Reverting chara {chara}", applicationId, tempHandler.Name); + bool reverted = await _characterHandler.RevertHandledChara(tempHandler.Name).ConfigureAwait(false); + if (reverted) + await Task.Delay(TimeSpan.FromSeconds(3)).ConfigureAwait(false); + + Logger.LogTrace("[{appId}] Applying data in Penumbra", applicationId); + + DataApplicationProgress = "Applying Penumbra information"; + penumbraCollection = await _ipcManager.Penumbra.CreateTemporaryCollectionAsync(Logger, metaInfo.Uploader.UID + metaInfo.Id).ConfigureAwait(false); + var idx = await _dalamudUtilService.RunOnFrameworkThread(() => tempHandler.GetGameObject()?.ObjectIndex).ConfigureAwait(false) ?? 0; + await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, idx).ConfigureAwait(false); + await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, applicationId, penumbraCollection, modPaths).ConfigureAwait(false); + await _ipcManager.Penumbra.SetManipulationDataAsync(Logger, applicationId, penumbraCollection, manipData ?? string.Empty).ConfigureAwait(false); + + Logger.LogTrace("[{appId}] Applying Glamourer data and Redrawing", applicationId); + DataApplicationProgress = "Applying Glamourer and redrawing Character"; + await _ipcManager.Glamourer.ApplyAllAsync(Logger, tempHandler, glamourerData, applicationId, token).ConfigureAwait(false); + await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, token).ConfigureAwait(false); + await _dalamudUtilService.WaitWhileCharacterIsDrawing(Logger, tempHandler, applicationId, ct: token).ConfigureAwait(false); + Logger.LogTrace("[{appId}] Removing collection", applicationId); + await _ipcManager.Penumbra.RemoveTemporaryCollectionAsync(Logger, applicationId, penumbraCollection).ConfigureAwait(false); + + DataApplicationProgress = "Applying Customize+ data"; + Logger.LogTrace("[{appId}] Appplying C+ data", applicationId); + + if (!string.IsNullOrEmpty(customizeData)) + { + cPlusId = await _ipcManager.CustomizePlus.SetBodyScaleAsync(tempHandler.Address, customizeData).ConfigureAwait(false); + } + else + { + cPlusId = await _ipcManager.CustomizePlus.SetBodyScaleAsync(tempHandler.Address, Convert.ToBase64String(Encoding.UTF8.GetBytes("{}"))).ConfigureAwait(false); + } + + if (autoRevert) + { + Logger.LogTrace("[{appId}] Starting wait for auto revert", applicationId); + + int i = 15; + while (i > 0) + { + DataApplicationProgress = $"All data applied. Reverting automatically in {i} seconds."; + await Task.Delay(TimeSpan.FromSeconds(1), token).ConfigureAwait(false); + i--; + } + } + else + { + Logger.LogTrace("[{appId}] Adding {name} to handled objects", applicationId, tempHandler.Name); + + _characterHandler.AddHandledChara(new HandledCharaDataEntry(tempHandler.Name, isSelf, cPlusId, metaInfo)); + } + } + finally + { + if (token.IsCancellationRequested) + DataApplicationProgress = "Application aborted. Reverting Character..."; + else if (autoRevert) + DataApplicationProgress = "Application finished. Reverting Character..."; + if (autoRevert) + { + await _characterHandler.RevertChara(tempHandler.Name, cPlusId).ConfigureAwait(false); + } + + if (!_dalamudUtilService.IsInGpose) + Mediator.Publish(new HaltCharaDataCreation(Resume: true)); + + if (_configService.Current.FavoriteCodes.TryGetValue(metaInfo.Uploader.UID + ":" + metaInfo.Id, out var favorite) && favorite != null) + { + favorite.LastDownloaded = DateTime.UtcNow; + _configService.Save(); + } + + DataApplicationTask = null; + DataApplicationProgress = string.Empty; + } + } + + private async Task CharaUpdateAsync(CharaDataExtendedUpdateDto updateDto) + { + Logger.LogDebug("Uploading Chara Data to Server"); + var baseUpdateDto = updateDto.BaseDto; + if (baseUpdateDto.FileGamePaths != null) + { + Logger.LogDebug("Detected file path changes, starting file upload"); + + UploadTask = UploadFiles(baseUpdateDto.FileGamePaths); + var result = await UploadTask.ConfigureAwait(false); + if (!result.Success) + { + return; + } + } + + Logger.LogDebug("Pushing update dto to server: {data}", baseUpdateDto); + + var res = await _apiController.CharaDataUpdate(baseUpdateDto).ConfigureAwait(false); + await AddOrUpdateDto(res).ConfigureAwait(false); + CharaUpdateTask = null; + } + + private async Task DownloadAndAplyDataAsync(string charaName, CharaDataDownloadDto charaDataDownloadDto, CharaDataMetaInfoDto metaInfo, bool autoRevert = true) + { + _applicationCts = _applicationCts.CancelRecreate(); + var token = _applicationCts.Token; + ICharacter? chara = (await _dalamudUtilService.GetGposeCharacterFromObjectTableByNameAsync(charaName, _dalamudUtilService.IsInGpose).ConfigureAwait(false)); + + if (chara == null) + return; + + var applicationId = Guid.NewGuid(); + + var playerChar = await _dalamudUtilService.GetPlayerCharacterAsync().ConfigureAwait(false); + bool isSelf = playerChar != null && string.Equals(playerChar.Name.TextValue, chara.Name.TextValue, StringComparison.Ordinal); + + DataApplicationProgress = "Checking local files"; + + Logger.LogTrace("[{appId}] Computing local missing files", applicationId); + + Dictionary modPaths; + List missingFiles; + _fileHandler.ComputeMissingFiles(charaDataDownloadDto, out modPaths, out missingFiles); + + Logger.LogTrace("[{appId}] Computing local missing files", applicationId); + + using GameObjectHandler? tempHandler = await _characterHandler.TryCreateGameObjectHandler(chara.ObjectIndex).ConfigureAwait(false); + if (tempHandler == null) return; + + if (missingFiles.Any()) + { + try + { + DataApplicationProgress = "Downloading Missing Files. Please be patient."; + await _fileHandler.DownloadFilesAsync(tempHandler, missingFiles, modPaths, token).ConfigureAwait(false); + } + catch (FileNotFoundException) + { + DataApplicationProgress = "Failed to download one or more files. Aborting."; + DataApplicationTask = null; + return; + } + catch (OperationCanceledException) + { + DataApplicationProgress = "Application aborted."; + DataApplicationTask = null; + return; + } + } + + if (!_dalamudUtilService.IsInGpose) + Mediator.Publish(new HaltCharaDataCreation()); + + var extendedMetaInfo = await CacheData(metaInfo).ConfigureAwait(false); + + await ApplyDataAsync(applicationId, tempHandler, isSelf, autoRevert, extendedMetaInfo, modPaths, charaDataDownloadDto.ManipulationData, charaDataDownloadDto.GlamourerData, + charaDataDownloadDto.CustomizeData, token).ConfigureAwait(false); + } + + private async Task<(string Result, bool Success)> UploadFiles(List missingFileList, Func? postUpload = null) + { + UploadProgress = new ValueProgress(); + try + { + _uploadCts = _uploadCts.CancelRecreate(); + var missingFiles = await _fileHandler.UploadFiles([.. missingFileList.Select(k => k.HashOrFileSwap)], UploadProgress, _uploadCts.Token).ConfigureAwait(false); + if (missingFiles.Any()) + { + Logger.LogInformation("Failed to upload {files}", string.Join(", ", missingFiles)); + return ($"Upload failed: {missingFiles.Count} missing or forbidden to upload local files.", false); + } + + if (postUpload != null) + await postUpload.Invoke().ConfigureAwait(false); + + return ("Upload sucessful", true); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Error during upload"); + if (ex is OperationCanceledException) + { + return ("Upload Cancelled", false); + } + return ("Error in upload, see log for more details", false); + } + finally + { + UploadTask = null; + UploadProgress = null; + } + } + + public void RevertChara(HandledCharaDataEntry? handled) + { + UiBlockingComputation = _characterHandler.RevertHandledChara(handled); + } + + internal void RemoveChara(string handledActor) + { + if (string.IsNullOrEmpty(handledActor)) return; + UiBlockingComputation = Task.Run(async () => + { + await _characterHandler.RevertHandledChara(handledActor, false).ConfigureAwait(false); + var gposeChara = await _dalamudUtilService.GetGposeCharacterFromObjectTableByNameAsync(handledActor, true).ConfigureAwait(false); + if (gposeChara != null) + await _ipcManager.Brio.DespawnActorAsync(gposeChara.Address).ConfigureAwait(false); + }); + } +} diff --git a/MareSynchronos/Services/CharaData/CharaDataNearbyManager.cs b/MareSynchronos/Services/CharaData/CharaDataNearbyManager.cs new file mode 100644 index 0000000..74058f7 --- /dev/null +++ b/MareSynchronos/Services/CharaData/CharaDataNearbyManager.cs @@ -0,0 +1,288 @@ +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.Diagnostics.Eventing.Reader; +using System.Numerics; + +namespace MareSynchronos.Services; + +internal sealed class CharaDataNearbyManager : DisposableMediatorSubscriberBase +{ + internal record NearbyCharaDataEntry + { + public float Direction { get; init; } + public float Distance { get; init; } + } + + private readonly DalamudUtilService _dalamudUtilService; + private readonly Dictionary _nearbyData = []; + private readonly Dictionary _poseVfx = []; + private readonly ServerConfigurationManager _serverConfigurationManager; + private readonly CharaDataConfigService _charaDataConfigService; + private readonly Dictionary> _metaInfoCache = []; + private readonly VfxSpawnManager _vfxSpawnManager; + private Task? _filterEntriesRunningTask; + private (Guid VfxId, PoseEntryExtended Pose)? _hoveredVfx = null; + private DateTime _lastExecutionTime = DateTime.UtcNow; + public CharaDataNearbyManager(ILogger logger, MareMediator mediator, + DalamudUtilService dalamudUtilService, VfxSpawnManager vfxSpawnManager, + ServerConfigurationManager serverConfigurationManager, + CharaDataConfigService charaDataConfigService) : base(logger, mediator) + { + mediator.Subscribe(this, (_) => HandleFrameworkUpdate()); + mediator.Subscribe(this, (_) => HandleFrameworkUpdate()); + _dalamudUtilService = dalamudUtilService; + _vfxSpawnManager = vfxSpawnManager; + _serverConfigurationManager = serverConfigurationManager; + _charaDataConfigService = charaDataConfigService; + mediator.Subscribe(this, (_) => ClearAllVfx()); + } + + public bool ComputeNearbyData { get; set; } = false; + + public IDictionary NearbyData => _nearbyData; + + public string UserNoteFilter { get; set; } = string.Empty; + + public void UpdateSharedData(Dictionary newData) + { + _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); + } + } + + 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) + await _dalamudUtilService.RunOnFrameworkThread(() => ManageWispsNearby(previousPoses)).ConfigureAwait(false); + } + + private unsafe void HandleFrameworkUpdate() + { + if (_lastExecutionTime.AddSeconds(0.5) > DateTime.UtcNow) return; + _lastExecutionTime = DateTime.UtcNow; + if (!ComputeNearbyData) + { + if (_nearbyData.Any()) + _nearbyData.Clear(); + if (_poseVfx.Any()) + ClearAllVfx(); + return; + } + + if (!_charaDataConfigService.Current.NearbyDrawWisps || _dalamudUtilService.IsInGpose) + 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) + _filterEntriesRunningTask = FilterEntriesAsync(cameraPos, lookAt); + } + + private void ManageWispsNearby(List 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); + } + } + } +} diff --git a/MareSynchronos/PlayerData/Export/MareCharaFileDataFactory.cs b/MareSynchronos/Services/CharaData/MareCharaFileDataFactory.cs similarity index 84% rename from MareSynchronos/PlayerData/Export/MareCharaFileDataFactory.cs rename to MareSynchronos/Services/CharaData/MareCharaFileDataFactory.cs index 7e011cf..f0a1d4d 100644 --- a/MareSynchronos/PlayerData/Export/MareCharaFileDataFactory.cs +++ b/MareSynchronos/Services/CharaData/MareCharaFileDataFactory.cs @@ -1,7 +1,8 @@ using MareSynchronos.API.Data; using MareSynchronos.FileCache; +using MareSynchronos.Services.CharaData.Models; -namespace MareSynchronos.PlayerData.Export; +namespace MareSynchronos.Services.CharaData; internal sealed class MareCharaFileDataFactory { diff --git a/MareSynchronos/Services/CharaData/Models/CharaDataExtendedUpdateDto.cs b/MareSynchronos/Services/CharaData/Models/CharaDataExtendedUpdateDto.cs new file mode 100644 index 0000000..9b431d9 --- /dev/null +++ b/MareSynchronos/Services/CharaData/Models/CharaDataExtendedUpdateDto.cs @@ -0,0 +1,332 @@ +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(); + _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, + 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? 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? 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 UserList => _userList; + private readonly List _userList; + public IEnumerable PoseList => _poseList; + private readonly List _poseList; + + public void AddToList(string user) + { + _userList.Add(new(user, null)); + UpdateAllowedUsers(); + } + + 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; + } + } + + public void RemoveFromList(string user) + { + _userList.RemoveAll(u => string.Equals(u.UID, user, StringComparison.Ordinal)); + UpdateAllowedUsers(); + } + + 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; + 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 + || 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); +} diff --git a/MareSynchronos/Services/CharaData/Models/CharaDataFullExtendedDto.cs b/MareSynchronos/Services/CharaData/Models/CharaDataFullExtendedDto.cs new file mode 100644 index 0000000..35bf813 --- /dev/null +++ b/MareSynchronos/Services/CharaData/Models/CharaDataFullExtendedDto.cs @@ -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(baseDto.OriginalFiles.Except(baseDto.FileGamePaths).ToList()); + HasMissingFiles = MissingFiles.Any(); + } + + public string FullId { get; set; } + public bool HasMissingFiles { get; init; } + public IReadOnlyCollection MissingFiles { get; init; } +} diff --git a/MareSynchronos/Services/CharaData/Models/CharaDataMetaInfoExtendedDto.cs b/MareSynchronos/Services/CharaData/Models/CharaDataMetaInfoExtendedDto.cs new file mode 100644 index 0000000..763056b --- /dev/null +++ b/MareSynchronos/Services/CharaData/Models/CharaDataMetaInfoExtendedDto.cs @@ -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 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 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; + } +} diff --git a/MareSynchronos/Services/CharaData/Models/HandledCharaDataEntry.cs b/MareSynchronos/Services/CharaData/Models/HandledCharaDataEntry.cs new file mode 100644 index 0000000..6b45b79 --- /dev/null +++ b/MareSynchronos/Services/CharaData/Models/HandledCharaDataEntry.cs @@ -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; +} diff --git a/MareSynchronos/PlayerData/Export/MareCharaFileData.cs b/MareSynchronos/Services/CharaData/Models/MareCharaFileData.cs similarity index 97% rename from MareSynchronos/PlayerData/Export/MareCharaFileData.cs rename to MareSynchronos/Services/CharaData/Models/MareCharaFileData.cs index 171b28c..0dde199 100644 --- a/MareSynchronos/PlayerData/Export/MareCharaFileData.cs +++ b/MareSynchronos/Services/CharaData/Models/MareCharaFileData.cs @@ -4,7 +4,7 @@ using MareSynchronos.FileCache; using System.Text; using System.Text.Json; -namespace MareSynchronos.PlayerData.Export; +namespace MareSynchronos.Services.CharaData.Models; public record MareCharaFileData { diff --git a/MareSynchronos/PlayerData/Export/MareCharaFileHeader.cs b/MareSynchronos/Services/CharaData/Models/MareCharaFileHeader.cs similarity index 96% rename from MareSynchronos/PlayerData/Export/MareCharaFileHeader.cs rename to MareSynchronos/Services/CharaData/Models/MareCharaFileHeader.cs index e49f9cc..43f6ee5 100644 --- a/MareSynchronos/PlayerData/Export/MareCharaFileHeader.cs +++ b/MareSynchronos/Services/CharaData/Models/MareCharaFileHeader.cs @@ -1,4 +1,4 @@ -namespace MareSynchronos.PlayerData.Export; +namespace MareSynchronos.Services.CharaData.Models; public record MareCharaFileHeader(byte Version, MareCharaFileData CharaFileData) { diff --git a/MareSynchronos/Services/CharaData/Models/PoseEntryExtended.cs b/MareSynchronos/Services/CharaData/Models/PoseEntryExtended.cs new file mode 100644 index 0000000..c48cb2c --- /dev/null +++ b/MareSynchronos/Services/CharaData/Models/PoseEntryExtended.cs @@ -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 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; + } +} \ No newline at end of file diff --git a/MareSynchronos/Services/CommandManagerService.cs b/MareSynchronos/Services/CommandManagerService.cs index 33de948..cf4dd4c 100644 --- a/MareSynchronos/Services/CommandManagerService.cs +++ b/MareSynchronos/Services/CommandManagerService.cs @@ -107,7 +107,7 @@ public sealed class CommandManagerService : IDisposable } else if (string.Equals(splitArgs[0], "gpose", StringComparison.OrdinalIgnoreCase)) { - _mediator.Publish(new UiToggleMessage(typeof(GposeUi))); + _mediator.Publish(new UiToggleMessage(typeof(CharaDataHubUi))); } else if (string.Equals(splitArgs[0], "rescan", StringComparison.OrdinalIgnoreCase)) { diff --git a/MareSynchronos/Services/DalamudUtilService.cs b/MareSynchronos/Services/DalamudUtilService.cs index f943dc7..6e95a56 100644 --- a/MareSynchronos/Services/DalamudUtilService.cs +++ b/MareSynchronos/Services/DalamudUtilService.cs @@ -1,10 +1,16 @@ 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; @@ -13,6 +19,7 @@ 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; @@ -83,6 +90,43 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber .Where(x => x.RowId != 0 && !(x.RowId >= 500 && (x.Dark & 0xFFFFFF00) == 0)) .ToDictionary(x => (int)x.RowId); }); + TerritoryData = new(() => + { + return gameData.GetExcelSheet(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(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(this, (msg) => { if (clientState.IsPvP) return; @@ -103,7 +147,11 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber } public bool IsWine { get; init; } - public unsafe GameObject* GposeTarget => TargetSystem.Instance()->GPoseTarget; + public unsafe GameObject* GposeTarget + { + get => TargetSystem.Instance()->GPoseTarget; + set => TargetSystem.Instance()->GPoseTarget = value; + } public unsafe Dalamud.Game.ClientState.Objects.Types.IGameObject? GposeTargetGameObject => GposeTarget == null ? null : _objectTable[GposeTarget->ObjectIndex]; public bool IsAnythingDrawing { get; private set; } = false; public bool IsInCutscene { get; private set; } = false; @@ -116,6 +164,8 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber public Lazy> WorldData { get; private set; } public Lazy> UiColors { get; private set; } + public Lazy> TerritoryData { get; private set; } + public Lazy> MapData { get; private set; } public MareMediator Mediator { get; } @@ -157,13 +207,23 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber return await RunOnFrameworkThread(() => GetCompanion(playerPointer)).ConfigureAwait(false); } - public Dalamud.Game.ClientState.Objects.Types.ICharacter? GetGposeCharacterFromObjectTableByName(string name, bool onlyGposeCharacters = false) + public async Task GetGposeCharacterFromObjectTableByNameAsync(string name, bool onlyGposeCharacters = false) + { + return await RunOnFrameworkThread(() => GetGposeCharacterFromObjectTableByName(name, onlyGposeCharacters)).ConfigureAwait(false); + } + + public ICharacter? GetGposeCharacterFromObjectTableByName(string name, bool onlyGposeCharacters = false) { EnsureIsOnFramework(); - return (Dalamud.Game.ClientState.Objects.Types.ICharacter?)_objectTable + return (ICharacter?)_objectTable .FirstOrDefault(i => (!onlyGposeCharacters || i.ObjectIndex >= 200) && string.Equals(i.Name.ToString(), name, StringComparison.Ordinal)); } + public IEnumerable GetGposeCharactersFromObjectTable() + { + return _objectTable.Where(o => o.ObjectIndex > 200 && o.ObjectKind == Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player).Cast(); + } + public bool GetIsPlayerPresent() { EnsureIsOnFramework(); @@ -203,6 +263,17 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber return await RunOnFrameworkThread(() => GetPet(playerPointer)).ConfigureAwait(false); } + public async Task GetPlayerCharacterAsync() + { + return await RunOnFrameworkThread(GetPlayerCharacter).ConfigureAwait(false); + } + + public IPlayerCharacter GetPlayerCharacter() + { + EnsureIsOnFramework(); + return _clientState.LocalPlayer!; + } + public IntPtr GetPlayerCharacterFromCachedTableByIdent(string characterName) { if (_playerCharas.TryGetValue(characterName, out var pchar)) return pchar.Address; @@ -248,6 +319,60 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber 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 GetMapDataAsync() + { + return await RunOnFrameworkThread(GetMapData).ConfigureAwait(false); + } + public async Task GetWorldIdAsync() { return await RunOnFrameworkThread(GetWorldId).ConfigureAwait(false); @@ -274,7 +399,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber return await RunOnFrameworkThread(() => IsObjectPresent(obj)).ConfigureAwait(false); } - public async Task RunOnFrameworkThread(Action act, [CallerMemberName] string callerMember = "", [CallerFilePath] string callerFilePath = "", [CallerLineNumber] int callerLineNumber = 0) + 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 () => @@ -534,13 +659,13 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber _lastGlobalBlockReason = string.Empty; } - if (GposeTarget != null && !IsInGpose) + if (_clientState.IsGPosing && !IsInGpose) { _logger.LogDebug("Gpose start"); IsInGpose = true; Mediator.Publish(new GposeStartMessage()); } - else if (GposeTarget == null && IsInGpose) + else if (!_clientState.IsGPosing && IsInGpose) { _logger.LogDebug("Gpose end"); IsInGpose = false; diff --git a/MareSynchronos/Services/Mediator/Messages.cs b/MareSynchronos/Services/Mediator/Messages.cs index 9243051..7fe9f4e 100644 --- a/MareSynchronos/Services/Mediator/Messages.cs +++ b/MareSynchronos/Services/Mediator/Messages.cs @@ -25,7 +25,7 @@ public record DelayedFrameworkUpdateMessage : SameThreadMessage; public record ZoneSwitchStartMessage : MessageBase; public record ZoneSwitchEndMessage : MessageBase; public record CutsceneStartMessage : MessageBase; -public record GposeStartMessage : MessageBase; +public record GposeStartMessage : SameThreadMessage; public record GposeEndMessage : MessageBase; public record CutsceneEndMessage : MessageBase; public record CutsceneFrameworkUpdateMessage : SameThreadMessage; @@ -99,6 +99,7 @@ public record PairDataAppliedMessage(string UID, CharacterData? CharacterData) : 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 PluginChangeMessage(string InternalName, Version Version, bool IsLoaded) : KeyedMessage(InternalName); #pragma warning restore S2094 diff --git a/MareSynchronos/UI/CharaDataHubUi.Functions.cs b/MareSynchronos/UI/CharaDataHubUi.Functions.cs new file mode 100644 index 0000000..62a9d72 --- /dev/null +++ b/MareSynchronos/UI/CharaDataHubUi.Functions.cs @@ -0,0 +1,194 @@ +using Dalamud.Interface.Utility.Raii; +using MareSynchronos.API.Dto.CharaData; +using MareSynchronos.MareConfiguration.Models; +using MareSynchronos.Services.CharaData.Models; +using System.Text; + +namespace MareSynchronos.UI; + +internal sealed partial class CharaDataHubUi +{ + private static string GetAccessTypeString(AccessTypeDto dto) => dto switch + { + AccessTypeDto.AllPairs => "All Pairs", + AccessTypeDto.ClosePairs => "Close Pairs", + AccessTypeDto.Individuals => "Specified", + AccessTypeDto.Public => "Everyone" + }; + + private static string GetShareTypeString(ShareTypeDto dto) => dto switch + { + ShareTypeDto.Private => "Private", + ShareTypeDto.Shared => "Shared" + }; + + private static string GetWorldDataTooltipText(PoseEntryExtended poseEntry) + { + if (!poseEntry.HasWorldData) return "This Pose has no world data attached."; + return poseEntry.WorldDataDescriptor; + } + + + private void GposeMetaInfoAction(Action gposeActionDraw, string actionDescription, CharaDataMetaInfoExtendedDto? dto, bool hasValidGposeTarget, bool isSpawning) + { + StringBuilder sb = new StringBuilder(); + + sb.AppendLine(actionDescription); + bool isDisabled = false; + + void AddErrorStart(StringBuilder sb) + { + sb.Append(UiSharedService.TooltipSeparator); + sb.AppendLine("Cannot execute:"); + } + + if (dto == null) + { + if (!isDisabled) AddErrorStart(sb); + sb.AppendLine("- No metainfo present"); + isDisabled = true; + } + if (!dto?.CanBeDownloaded ?? false) + { + if (!isDisabled) AddErrorStart(sb); + sb.AppendLine("- Character is not downloadable"); + isDisabled = true; + } + if (!_uiSharedService.IsInGpose) + { + if (!isDisabled) AddErrorStart(sb); + sb.AppendLine("- Requires to be in GPose"); + isDisabled = true; + } + if (!hasValidGposeTarget && !isSpawning) + { + if (!isDisabled) AddErrorStart(sb); + sb.AppendLine("- Requires a valid GPose target"); + isDisabled = true; + } + if (isSpawning && !_charaDataManager.BrioAvailable) + { + if (!isDisabled) AddErrorStart(sb); + sb.AppendLine("- Requires Brio to be installed."); + isDisabled = true; + } + + using (ImRaii.Group()) + { + using var dis = ImRaii.Disabled(isDisabled); + gposeActionDraw.Invoke(dto); + } + if (sb.Length > 0) + { + UiSharedService.AttachToolTip(sb.ToString()); + } + } + + private void GposePoseAction(Action poseActionDraw, string poseDescription, bool hasValidGposeTarget) + { + StringBuilder sb = new StringBuilder(); + + sb.AppendLine(poseDescription); + bool isDisabled = false; + + void AddErrorStart(StringBuilder sb) + { + sb.Append(UiSharedService.TooltipSeparator); + sb.AppendLine("Cannot execute:"); + } + + if (!_uiSharedService.IsInGpose) + { + if (!isDisabled) AddErrorStart(sb); + sb.AppendLine("- Requires to be in GPose"); + isDisabled = true; + } + if (!hasValidGposeTarget) + { + if (!isDisabled) AddErrorStart(sb); + sb.AppendLine("- Requires a valid GPose target"); + isDisabled = true; + } + if (!_charaDataManager.BrioAvailable) + { + if (!isDisabled) AddErrorStart(sb); + sb.AppendLine("- Requires Brio to be installed."); + isDisabled = true; + } + + using (ImRaii.Group()) + { + using var dis = ImRaii.Disabled(isDisabled); + poseActionDraw.Invoke(); + } + if (sb.Length > 0) + { + UiSharedService.AttachToolTip(sb.ToString()); + } + } + + private void SetWindowSizeConstraints(bool? inGposeTab = null) + { + SizeConstraints = new() + { + MinimumSize = new((inGposeTab ?? false) ? 400 : 1000, 500), + MaximumSize = new((inGposeTab ?? false) ? 400 : 1000, 2000) + }; + } + + private void UpdateFilteredFavorites() + { + _ = Task.Run(async () => + { + if (_charaDataManager.DownloadMetaInfoTask != null) + { + await _charaDataManager.DownloadMetaInfoTask.ConfigureAwait(false); + } + Dictionary newFiltered = []; + foreach (var favorite in _configService.Current.FavoriteCodes) + { + var uid = favorite.Key.Split(":")[0]; + var note = _serverConfigurationManager.GetNoteForUid(uid) ?? string.Empty; + bool hasMetaInfo = _charaDataManager.TryGetMetaInfo(favorite.Key, out var metaInfo); + bool addFavorite = + (string.IsNullOrEmpty(_filterCodeNote) + || (note.Contains(_filterCodeNote, StringComparison.OrdinalIgnoreCase) + || uid.Contains(_filterCodeNote, StringComparison.OrdinalIgnoreCase))) + && (string.IsNullOrEmpty(_filterDescription) + || (favorite.Value.CustomDescription.Contains(_filterDescription, StringComparison.OrdinalIgnoreCase) + || (metaInfo != null && metaInfo!.Description.Contains(_filterDescription, StringComparison.OrdinalIgnoreCase)))) + && (!_filterPoseOnly + || (metaInfo != null && metaInfo!.HasPoses)) + && (!_filterWorldOnly + || (metaInfo != null && metaInfo!.HasWorldData)); + if (addFavorite) + { + newFiltered[favorite.Key] = (favorite.Value, metaInfo, hasMetaInfo); + } + } + + _filteredFavorites = newFiltered; + }); + } + + private void UpdateFilteredItems() + { + if (_charaDataManager.GetSharedWithYouTask == null) + { + _filteredDict = _charaDataManager.SharedWithYouData + .SelectMany(k => k.Value) + .Where(k => + (!_sharedWithYouDownloadableFilter || k.CanBeDownloaded) + && (string.IsNullOrEmpty(_sharedWithYouDescriptionFilter) || k.Description.Contains(_sharedWithYouDescriptionFilter, StringComparison.OrdinalIgnoreCase))) + .GroupBy(k => k.Uploader) + .ToDictionary(k => + { + var note = _serverConfigurationManager.GetNoteForUid(k.Key.UID); + if (note == null) return k.Key.AliasOrUID; + return $"{note} ({k.Key.AliasOrUID})"; + }, k => k.ToList(), StringComparer.OrdinalIgnoreCase) + .Where(k => (string.IsNullOrEmpty(_sharedWithYouOwnerFilter) || k.Key.Contains(_sharedWithYouOwnerFilter, StringComparison.OrdinalIgnoreCase))) + .OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase).ToDictionary(); + } + } +} diff --git a/MareSynchronos/UI/CharaDataHubUi.McdOnline.cs b/MareSynchronos/UI/CharaDataHubUi.McdOnline.cs new file mode 100644 index 0000000..f8d8cf1 --- /dev/null +++ b/MareSynchronos/UI/CharaDataHubUi.McdOnline.cs @@ -0,0 +1,738 @@ +using Dalamud.Interface.Colors; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Interface.Utility; +using Dalamud.Interface; +using ImGuiNET; +using MareSynchronos.API.Dto.CharaData; +using MareSynchronos.Services.CharaData.Models; +using System.Numerics; + +namespace MareSynchronos.UI; + +internal sealed partial class CharaDataHubUi +{ + private void DrawEditCharaData(CharaDataFullExtendedDto? dataDto) + { + using var imguiid = ImRaii.PushId(dataDto?.Id ?? "NoData"); + + if (dataDto == null) + { + ImGuiHelpers.ScaledDummy(5); + UiSharedService.DrawGroupedCenteredColorText("Select an entry above to edit its data.", ImGuiColors.DalamudYellow); + return; + } + + var updateDto = _charaDataManager.GetUpdateDto(dataDto.Id); + + if (updateDto == null) + { + UiSharedService.DrawGroupedCenteredColorText("Something went awfully wrong and there's no update DTO. Try updating Character Data via the button above.", ImGuiColors.DalamudYellow); + return; + } + + bool canUpdate = updateDto.HasChanges; + if (canUpdate || _charaDataManager.CharaUpdateTask != null) + { + ImGuiHelpers.ScaledDummy(5); + } + + var indent = ImRaii.PushIndent(10f); + if (canUpdate || (!_charaDataManager.UploadTask?.IsCompleted ?? false)) + { + UiSharedService.DrawGrouped(() => + { + if (canUpdate) + { + ImGui.AlignTextToFramePadding(); + UiSharedService.ColorTextWrapped("Warning: You have unsaved changes!", ImGuiColors.DalamudRed); + ImGui.SameLine(); + using (ImRaii.Disabled(_charaDataManager.CharaUpdateTask != null && !_charaDataManager.CharaUpdateTask.IsCompleted)) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowCircleUp, "Save to Server")) + { + _charaDataManager.UploadCharaData(dataDto.Id); + } + ImGui.SameLine(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Undo, "Undo all changes")) + { + updateDto.UndoChanges(); + } + } + if (_charaDataManager.CharaUpdateTask != null && !_charaDataManager.CharaUpdateTask.IsCompleted) + { + UiSharedService.ColorTextWrapped("Updating data on server, please wait.", ImGuiColors.DalamudYellow); + } + } + + if (!_charaDataManager.UploadTask?.IsCompleted ?? false) + { + DisableDisabled(() => + { + if (_charaDataManager.UploadProgress != null) + { + UiSharedService.ColorTextWrapped(_charaDataManager.UploadProgress.Value ?? string.Empty, ImGuiColors.DalamudYellow); + } + if ((!_charaDataManager.UploadTask?.IsCompleted ?? false) && _uiSharedService.IconTextButton(FontAwesomeIcon.Ban, "Cancel Upload")) + { + _charaDataManager.CancelUpload(); + } + else if (_charaDataManager.UploadTask?.IsCompleted ?? false) + { + var color = UiSharedService.GetBoolColor(_charaDataManager.UploadTask.Result.Success); + UiSharedService.ColorTextWrapped(_charaDataManager.UploadTask.Result.Output, color); + } + }); + } + }); + } + indent.Dispose(); + + if (canUpdate || _charaDataManager.CharaUpdateTask != null) + { + ImGuiHelpers.ScaledDummy(5); + } + + using var child = ImRaii.Child("editChild", new(0, 0), false, ImGuiWindowFlags.AlwaysAutoResize); + + DrawEditCharaDataGeneral(dataDto, updateDto); + ImGuiHelpers.ScaledDummy(5); + DrawEditCharaDataAccessAndSharing(updateDto); + ImGuiHelpers.ScaledDummy(5); + DrawEditCharaDataAppearance(dataDto, updateDto); + ImGuiHelpers.ScaledDummy(5); + DrawEditCharaDataPoses(updateDto); + } + + private void DrawEditCharaDataAccessAndSharing(CharaDataExtendedUpdateDto updateDto) + { + _uiSharedService.BigText("Access and Sharing"); + + ImGui.SetNextItemWidth(200); + var dtoAccessType = updateDto.AccessType; + if (ImGui.BeginCombo("Access Restrictions", GetAccessTypeString(dtoAccessType))) + { + foreach (var accessType in Enum.GetValues(typeof(AccessTypeDto)).Cast()) + { + if (ImGui.Selectable(GetAccessTypeString(accessType), accessType == dtoAccessType)) + { + updateDto.AccessType = accessType; + } + } + + ImGui.EndCombo(); + } + _uiSharedService.DrawHelpText("You can control who has access to your character data based on the access restrictions." + UiSharedService.TooltipSeparator + + "Specified: Only people you directly specify in 'Specific Individuals' can access this character data" + Environment.NewLine + + "Close Pairs: Only people you have directly paired can access this character data" + Environment.NewLine + + "All Pairs: All people you have paired can access this character data" + Environment.NewLine + + "Everyone: Everyone can access this character data" + UiSharedService.TooltipSeparator + + "Note: To access your character data the person in question requires to have the code. Exceptions for 'Shared' data, see 'Sharing' below." + Environment.NewLine + + "Note: For 'Close' and 'All Pairs' the pause state plays a role. Paused people will not be able to access your character data." + Environment.NewLine + + "Note: Directly specified individuals in the 'Specific Individuals' list will be able to access your character data regardless of pause or pair state."); + + DrawSpecificIndividuals(updateDto); + + ImGui.SetNextItemWidth(200); + var dtoShareType = updateDto.ShareType; + using (ImRaii.Disabled(dtoAccessType == AccessTypeDto.Public)) + { + if (ImGui.BeginCombo("Sharing", GetShareTypeString(dtoShareType))) + { + foreach (var shareType in Enum.GetValues(typeof(ShareTypeDto)).Cast()) + { + if (ImGui.Selectable(GetShareTypeString(shareType), shareType == dtoShareType)) + { + updateDto.ShareType = shareType; + } + } + + ImGui.EndCombo(); + } + } + _uiSharedService.DrawHelpText("This regulates how you want to distribute this character data." + UiSharedService.TooltipSeparator + + "Private: People require to have the code to download this character data" + Environment.NewLine + + "Shared: People that are allowed through 'Access Restrictions' will have this character data entry displayed in 'Shared with You'" + UiSharedService.TooltipSeparator + + "Note: Shared is incompatible with Access Restriction 'Everyone'"); + + ImGuiHelpers.ScaledDummy(10f); + } + + private void DrawEditCharaDataAppearance(CharaDataFullExtendedDto dataDto, CharaDataExtendedUpdateDto updateDto) + { + _uiSharedService.BigText("Appearance"); + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowRight, "Set Appearance to Current Appearance")) + { + _charaDataManager.SetAppearanceData(dataDto.Id); + } + _uiSharedService.DrawHelpText("This will overwrite the appearance data currently stored in this Character Data entry with your current appearance."); + ImGui.SameLine(); + using (ImRaii.Disabled(dataDto.HasMissingFiles || !updateDto.IsAppearanceEqual || _charaDataManager.DataApplicationTask != null)) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.CheckCircle, "Preview Saved Apperance on Self")) + { + _charaDataManager.ApplyDataToSelf(dataDto); + } + } + _uiSharedService.DrawHelpText("This will download and apply the saved character data to yourself. Once loaded it will automatically revert itself within 15 seconds." + UiSharedService.TooltipSeparator + + "Note: Weapons will not be displayed correctly unless using the same job as the saved data."); + + ImGui.TextUnformatted("Contains Glamourer Data"); + ImGui.SameLine(); + bool hasGlamourerdata = !string.IsNullOrEmpty(updateDto.GlamourerData); + ImGui.SameLine(200); + _uiSharedService.BooleanToColoredIcon(hasGlamourerdata, false); + + ImGui.TextUnformatted("Contains Files"); + var hasFiles = (updateDto.FileGamePaths ?? []).Any() || (dataDto.OriginalFiles.Any()); + ImGui.SameLine(200); + _uiSharedService.BooleanToColoredIcon(hasFiles, false); + if (hasFiles && updateDto.IsAppearanceEqual) + { + ImGui.SameLine(); + ImGuiHelpers.ScaledDummy(20, 1); + ImGui.SameLine(); + var pos = ImGui.GetCursorPosX(); + ImGui.NewLine(); + ImGui.SameLine(pos); + ImGui.TextUnformatted($"{dataDto.FileGamePaths.DistinctBy(k => k.HashOrFileSwap).Count()} unique file hashes (original upload: {dataDto.OriginalFiles.DistinctBy(k => k.HashOrFileSwap).Count()} file hashes)"); + ImGui.NewLine(); + ImGui.SameLine(pos); + ImGui.TextUnformatted($"{dataDto.FileGamePaths.Count} associated game paths"); + ImGui.NewLine(); + ImGui.SameLine(pos); + ImGui.TextUnformatted($"{dataDto.FileSwaps!.Count} file swaps"); + ImGui.NewLine(); + ImGui.SameLine(pos); + if (!dataDto.HasMissingFiles) + { + UiSharedService.ColorTextWrapped("All files to download this character data are present on the server", ImGuiColors.HealerGreen); + } + else + { + UiSharedService.ColorTextWrapped($"{dataDto.MissingFiles.DistinctBy(k => k.HashOrFileSwap).Count()} files to download this character data are missing on the server", ImGuiColors.DalamudRed); + ImGui.NewLine(); + ImGui.SameLine(pos); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowCircleUp, "Attempt to upload missing files and restore Character Data")) + { + _charaDataManager.UploadMissingFiles(dataDto.Id); + } + } + } + else if (hasFiles && !updateDto.IsAppearanceEqual) + { + ImGui.SameLine(); + ImGuiHelpers.ScaledDummy(20, 1); + ImGui.SameLine(); + UiSharedService.ColorTextWrapped("New data was set. It may contain files that require to be uploaded (will happen on Saving to server)", ImGuiColors.DalamudYellow); + } + + ImGui.TextUnformatted("Contains Manipulation Data"); + bool hasManipData = !string.IsNullOrEmpty(updateDto.ManipulationData); + ImGui.SameLine(200); + _uiSharedService.BooleanToColoredIcon(hasManipData, false); + + ImGui.TextUnformatted("Contains Customize+ Data"); + ImGui.SameLine(); + bool hasCustomizeData = !string.IsNullOrEmpty(updateDto.CustomizeData); + ImGui.SameLine(200); + _uiSharedService.BooleanToColoredIcon(hasCustomizeData, false); + } + + private void DrawEditCharaDataGeneral(CharaDataFullExtendedDto dataDto, CharaDataExtendedUpdateDto updateDto) + { + _uiSharedService.BigText("General"); + string code = dataDto.FullId; + using (ImRaii.Disabled()) + { + ImGui.SetNextItemWidth(200); + ImGui.InputText("##CharaDataCode", ref code, 255, ImGuiInputTextFlags.ReadOnly); + } + ImGui.SameLine(); + ImGui.TextUnformatted("Chara Data Code"); + ImGui.SameLine(); + if (_uiSharedService.IconButton(FontAwesomeIcon.Copy)) + { + ImGui.SetClipboardText(code); + } + UiSharedService.AttachToolTip("Copy Code to Clipboard"); + + string creationTime = dataDto.CreatedDate.ToLocalTime().ToString(); + string updateTime = dataDto.UpdatedDate.ToLocalTime().ToString(); + string downloadCount = dataDto.DownloadCount.ToString(); + using (ImRaii.Disabled()) + { + ImGui.SetNextItemWidth(200); + ImGui.InputText("##CreationDate", ref creationTime, 255, ImGuiInputTextFlags.ReadOnly); + } + ImGui.SameLine(); + ImGui.TextUnformatted("Creation Date"); + ImGui.SameLine(); + ImGuiHelpers.ScaledDummy(20); + ImGui.SameLine(); + using (ImRaii.Disabled()) + { + ImGui.SetNextItemWidth(200); + ImGui.InputText("##LastUpdate", ref updateTime, 255, ImGuiInputTextFlags.ReadOnly); + } + ImGui.SameLine(); + ImGui.TextUnformatted("Last Update Date"); + ImGui.SameLine(); + ImGuiHelpers.ScaledDummy(23); + ImGui.SameLine(); + using (ImRaii.Disabled()) + { + ImGui.SetNextItemWidth(50); + ImGui.InputText("##DlCount", ref downloadCount, 255, ImGuiInputTextFlags.ReadOnly); + } + ImGui.SameLine(); + ImGui.TextUnformatted("Download Count"); + + string description = updateDto.Description; + ImGui.SetNextItemWidth(735); + if (ImGui.InputText("##Description", ref description, 200)) + { + updateDto.Description = description; + } + ImGui.SameLine(); + ImGui.TextUnformatted("Description"); + _uiSharedService.DrawHelpText("Description for this Character Data." + UiSharedService.TooltipSeparator + + "Note: the description will be visible to anyone who can access this character data. See 'Access Restrictions' and 'Sharing' below."); + + var expiryDate = updateDto.ExpiryDate; + bool isExpiring = expiryDate != DateTime.MaxValue; + if (ImGui.Checkbox("Expires", ref isExpiring)) + { + updateDto.SetExpiry(isExpiring); + } + _uiSharedService.DrawHelpText("If expiration is enabled, the uploaded character data will be automatically deleted from the server at the specified date."); + using (ImRaii.Disabled(!isExpiring)) + { + ImGui.SameLine(); + ImGui.SetNextItemWidth(100); + if (ImGui.BeginCombo("Year", expiryDate.Year.ToString())) + { + for (int year = DateTime.UtcNow.Year; year < DateTime.UtcNow.Year + 4; year++) + { + if (ImGui.Selectable(year.ToString(), year == expiryDate.Year)) + { + updateDto.SetExpiry(year, expiryDate.Month, expiryDate.Day); + } + } + ImGui.EndCombo(); + } + ImGui.SameLine(); + + int daysInMonth = DateTime.DaysInMonth(expiryDate.Year, expiryDate.Month); + ImGui.SetNextItemWidth(100); + if (ImGui.BeginCombo("Month", expiryDate.Month.ToString())) + { + for (int month = 1; month <= 12; month++) + { + if (ImGui.Selectable(month.ToString(), month == expiryDate.Month)) + { + updateDto.SetExpiry(expiryDate.Year, month, expiryDate.Day); + } + } + ImGui.EndCombo(); + } + ImGui.SameLine(); + + ImGui.SetNextItemWidth(100); + if (ImGui.BeginCombo("Day", expiryDate.Day.ToString())) + { + for (int day = 1; day <= daysInMonth; day++) + { + if (ImGui.Selectable(day.ToString(), day == expiryDate.Day)) + { + updateDto.SetExpiry(expiryDate.Year, expiryDate.Month, day); + } + } + ImGui.EndCombo(); + } + } + ImGuiHelpers.ScaledDummy(5); + + using (ImRaii.Disabled(!UiSharedService.CtrlPressed())) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Delete Character Data")) + { + _ = _charaDataManager.DeleteCharaData(dataDto); + _selectedDtoId = string.Empty; + } + } + if (!UiSharedService.CtrlPressed()) + { + UiSharedService.AttachToolTip("Hold CTRL and click to delete the current data. This operation is irreversible."); + } + } + + private void DrawEditCharaDataPoses(CharaDataExtendedUpdateDto updateDto) + { + _uiSharedService.BigText("Poses"); + var poseCount = updateDto.PoseList.Count(); + using (ImRaii.Disabled(poseCount >= maxPoses)) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Plus, "Add new Pose")) + { + updateDto.AddPose(); + } + } + ImGui.SameLine(); + using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudYellow, poseCount == maxPoses)) + ImGui.TextUnformatted($"{poseCount}/{maxPoses} poses attached"); + ImGuiHelpers.ScaledDummy(5); + + using var indent = ImRaii.PushIndent(10f); + int poseNumber = 1; + + if (!_uiSharedService.IsInGpose && _charaDataManager.BrioAvailable) + { + ImGuiHelpers.ScaledDummy(5); + UiSharedService.DrawGroupedCenteredColorText("To attach pose and world data you need to be in GPose.", ImGuiColors.DalamudYellow); + ImGuiHelpers.ScaledDummy(5); + } + else if (!_charaDataManager.BrioAvailable) + { + ImGuiHelpers.ScaledDummy(5); + UiSharedService.DrawGroupedCenteredColorText("To attach pose and world data Brio requires to be installed.", ImGuiColors.DalamudRed); + ImGuiHelpers.ScaledDummy(5); + } + + foreach (var pose in updateDto.PoseList) + { + ImGui.AlignTextToFramePadding(); + using var id = ImRaii.PushId("pose" + poseNumber); + ImGui.TextUnformatted(poseNumber.ToString()); + + if (pose.Id == null) + { + ImGui.SameLine(50); + _uiSharedService.IconText(FontAwesomeIcon.Plus, ImGuiColors.DalamudYellow); + UiSharedService.AttachToolTip("This pose has not been added to the server yet. Save changes to upload this Pose data."); + } + + bool poseHasChanges = updateDto.PoseHasChanges(pose); + if (poseHasChanges) + { + ImGui.SameLine(50); + _uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, ImGuiColors.DalamudYellow); + UiSharedService.AttachToolTip("This pose has changes that have not been saved to the server yet."); + } + + ImGui.SameLine(75); + if (pose.Description == null && pose.WorldData == null && pose.PoseData == null) + { + UiSharedService.ColorText("Pose scheduled for deletion", ImGuiColors.DalamudYellow); + } + else + { + var desc = pose.Description; + if (ImGui.InputTextWithHint("##description", "Description", ref desc, 100)) + { + pose.Description = desc; + updateDto.UpdatePoseList(); + } + ImGui.SameLine(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Delete")) + { + updateDto.RemovePose(pose); + } + + ImGui.SameLine(); + ImGuiHelpers.ScaledDummy(10, 1); + ImGui.SameLine(); + bool hasPoseData = !string.IsNullOrEmpty(pose.PoseData); + _uiSharedService.IconText(FontAwesomeIcon.Running, UiSharedService.GetBoolColor(hasPoseData)); + UiSharedService.AttachToolTip(hasPoseData + ? "This Pose entry has pose data attached" + : "This Pose entry has no pose data attached"); + ImGui.SameLine(); + + using (ImRaii.Disabled(!_uiSharedService.IsInGpose || !(_charaDataManager.AttachingPoseTask?.IsCompleted ?? true) || !_charaDataManager.BrioAvailable)) + { + using var poseid = ImRaii.PushId("poseSet" + poseNumber); + if (_uiSharedService.IconButton(FontAwesomeIcon.Plus)) + { + _charaDataManager.AttachPoseData(pose, updateDto); + } + UiSharedService.AttachToolTip("Apply current pose data to pose"); + } + ImGui.SameLine(); + using (ImRaii.Disabled(!hasPoseData)) + { + using var poseid = ImRaii.PushId("poseDelete" + poseNumber); + if (_uiSharedService.IconButton(FontAwesomeIcon.Trash)) + { + pose.PoseData = string.Empty; + updateDto.UpdatePoseList(); + } + UiSharedService.AttachToolTip("Delete current pose data from pose"); + } + + ImGui.SameLine(); + ImGuiHelpers.ScaledDummy(10, 1); + ImGui.SameLine(); + var worldData = pose.WorldData; + bool hasWorldData = (worldData ?? default) != default; + _uiSharedService.IconText(FontAwesomeIcon.Globe, UiSharedService.GetBoolColor(hasWorldData)); + var tooltipText = !hasWorldData ? "This Pose has no world data attached." : "This Pose has world data attached."; + if (hasWorldData) + { + tooltipText += UiSharedService.TooltipSeparator + "Click to show location on map"; + } + UiSharedService.AttachToolTip(tooltipText); + if (hasWorldData && ImGui.IsItemClicked(ImGuiMouseButton.Left)) + { + _dalamudUtilService.SetMarkerAndOpenMap(position: new Vector3(worldData.Value.PositionX, worldData.Value.PositionY, worldData.Value.PositionZ), + _dalamudUtilService.MapData.Value[worldData.Value.LocationInfo.MapId].Map); + } + ImGui.SameLine(); + using (ImRaii.Disabled(!_uiSharedService.IsInGpose || !(_charaDataManager.AttachingPoseTask?.IsCompleted ?? true) || !_charaDataManager.BrioAvailable)) + { + using var worldId = ImRaii.PushId("worldSet" + poseNumber); + if (_uiSharedService.IconButton(FontAwesomeIcon.Plus)) + { + _charaDataManager.AttachWorldData(pose, updateDto); + } + UiSharedService.AttachToolTip("Apply current world position data to pose"); + } + ImGui.SameLine(); + using (ImRaii.Disabled(!hasWorldData)) + { + using var worldId = ImRaii.PushId("worldDelete" + poseNumber); + if (_uiSharedService.IconButton(FontAwesomeIcon.Trash)) + { + pose.WorldData = default(WorldData); + updateDto.UpdatePoseList(); + } + UiSharedService.AttachToolTip("Delete current world position data from pose"); + } + } + + if (poseHasChanges) + { + ImGui.SameLine(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Undo, "Undo")) + { + updateDto.RevertDeletion(pose); + } + } + + poseNumber++; + } + } + + private void DrawMcdOnline() + { + _uiSharedService.BigText("Mare Character Data Online"); + + DrawHelpFoldout("In this tab you can create, view and edit your own Mare Character Data that is stored on the server." + Environment.NewLine + Environment.NewLine + + "Mare Character Data Online functions similar to the previous MCDF standard for exporting your character, except that you do not have to send a file to the other person but solely a code." + Environment.NewLine + Environment.NewLine + + "There would be a bit too much to explain here on what you can do here in its entirety, however, all elements in this tab have help texts attached what they are used for. Please review them carefully." + Environment.NewLine + Environment.NewLine + + "Be mindful that when you share your Character Data with other people there is a chance that, with the help of unsanctioned 3rd party plugins, your appearance could be stolen irreversibly, just like when using MCDF."); + + ImGuiHelpers.ScaledDummy(5); + using (ImRaii.Disabled(_charaDataManager.GetAllDataTask != null + || (_charaDataManager.DataGetTimeoutTask != null && !_charaDataManager.DataGetTimeoutTask.IsCompleted))) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowCircleDown, "Download your Character Data from Server")) + { + _ = _charaDataManager.GetAllData(_disposalCts.Token); + } + } + if (_charaDataManager.DataGetTimeoutTask != null && !_charaDataManager.DataGetTimeoutTask.IsCompleted) + { + UiSharedService.AttachToolTip("You can only refresh all character data from server every minute. Please wait."); + } + + using (var table = ImRaii.Table("Own Character Data", 12, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.ScrollY, + new Vector2(ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X, 100))) + { + if (table) + { + ImGui.TableSetupColumn("", ImGuiTableColumnFlags.WidthFixed, 18); + ImGui.TableSetupColumn("", ImGuiTableColumnFlags.WidthFixed, 18); + ImGui.TableSetupColumn("Code"); + ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Created"); + ImGui.TableSetupColumn("Updated"); + ImGui.TableSetupColumn("Download Count", ImGuiTableColumnFlags.WidthFixed, 18); + ImGui.TableSetupColumn("Downloadable", ImGuiTableColumnFlags.WidthFixed, 18); + ImGui.TableSetupColumn("Files", ImGuiTableColumnFlags.WidthFixed, 32); + ImGui.TableSetupColumn("Glamourer", ImGuiTableColumnFlags.WidthFixed, 18); + ImGui.TableSetupColumn("Customize+", ImGuiTableColumnFlags.WidthFixed, 18); + ImGui.TableSetupColumn("Expires", ImGuiTableColumnFlags.WidthFixed, 18); + ImGui.TableSetupScrollFreeze(0, 1); + ImGui.TableHeadersRow(); + foreach (var entry in _charaDataManager.OwnCharaData.Values) + { + var uDto = _charaDataManager.GetUpdateDto(entry.Id); + ImGui.TableNextColumn(); + if (string.Equals(entry.Id, _selectedDtoId, StringComparison.Ordinal)) + _uiSharedService.IconText(FontAwesomeIcon.CaretRight); + + ImGui.TableNextColumn(); + DrawAddOrRemoveFavorite(entry); + + ImGui.TableNextColumn(); + var idText = entry.FullId; + if (uDto?.HasChanges ?? false) + { + UiSharedService.ColorText(idText, ImGuiColors.DalamudYellow); + UiSharedService.AttachToolTip("This entry has unsaved changes"); + } + else + { + ImGui.TextUnformatted(idText); + } + if (ImGui.IsItemClicked()) _selectedDtoId = entry.Id; + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(entry.Description); + if (ImGui.IsItemClicked()) _selectedDtoId = entry.Id; + UiSharedService.AttachToolTip(entry.Description); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(entry.CreatedDate.ToLocalTime().ToString()); + if (ImGui.IsItemClicked()) _selectedDtoId = entry.Id; + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(entry.UpdatedDate.ToLocalTime().ToString()); + if (ImGui.IsItemClicked()) _selectedDtoId = entry.Id; + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(entry.DownloadCount.ToString()); + if (ImGui.IsItemClicked()) _selectedDtoId = entry.Id; + + ImGui.TableNextColumn(); + bool isDownloadable = !entry.HasMissingFiles + && !string.IsNullOrEmpty(entry.GlamourerData); + _uiSharedService.BooleanToColoredIcon(isDownloadable, false); + if (ImGui.IsItemClicked()) _selectedDtoId = entry.Id; + UiSharedService.AttachToolTip(isDownloadable ? "Can be downloaded by others" : "Cannot be downloaded: Has missing files or data, please review this entry manually"); + + ImGui.TableNextColumn(); + var count = entry.FileGamePaths.Concat(entry.FileSwaps).Count(); + ImGui.TextUnformatted(count.ToString()); + if (ImGui.IsItemClicked()) _selectedDtoId = entry.Id; + UiSharedService.AttachToolTip(count == 0 ? "No File data attached" : "Has File data attached"); + + ImGui.TableNextColumn(); + bool hasGlamourerData = !string.IsNullOrEmpty(entry.GlamourerData); + _uiSharedService.BooleanToColoredIcon(hasGlamourerData, false); + if (ImGui.IsItemClicked()) _selectedDtoId = entry.Id; + UiSharedService.AttachToolTip(string.IsNullOrEmpty(entry.GlamourerData) ? "No Glamourer data attached" : "Has Glamourer data attached"); + + ImGui.TableNextColumn(); + bool hasCustomizeData = !string.IsNullOrEmpty(entry.CustomizeData); + _uiSharedService.BooleanToColoredIcon(hasCustomizeData, false); + if (ImGui.IsItemClicked()) _selectedDtoId = entry.Id; + UiSharedService.AttachToolTip(string.IsNullOrEmpty(entry.CustomizeData) ? "No Customize+ data attached" : "Has Customize+ data attached"); + + ImGui.TableNextColumn(); + FontAwesomeIcon eIcon = FontAwesomeIcon.None; + if (!Equals(DateTime.MaxValue, entry.ExpiryDate)) + eIcon = FontAwesomeIcon.Clock; + _uiSharedService.IconText(eIcon, ImGuiColors.DalamudYellow); + if (ImGui.IsItemClicked()) _selectedDtoId = entry.Id; + if (eIcon != FontAwesomeIcon.None) + { + UiSharedService.AttachToolTip($"This entry will expire on {entry.ExpiryDate.ToLocalTime()}"); + } + } + } + } + + using (ImRaii.Disabled(!_charaDataManager.Initialized || _charaDataManager.DataCreationTask != null || _charaDataManager.OwnCharaData.Count == _charaDataManager.MaxCreatableCharaData)) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Plus, "New Character Data Entry")) + { + _charaDataManager.CreateCharaDataEntry(_closalCts.Token); + } + } + if (_charaDataManager.DataCreationTask != null) + { + UiSharedService.AttachToolTip("You can only create new character data every few seconds. Please wait."); + } + if (!_charaDataManager.Initialized) + { + UiSharedService.AttachToolTip("Please use the button \"Get Own Chara Data\" once before you can add new data entries."); + } + + if (_charaDataManager.Initialized) + { + ImGui.SameLine(); + ImGui.AlignTextToFramePadding(); + UiSharedService.TextWrapped($"Chara Data Entries on Server: {_charaDataManager.OwnCharaData.Count}/{_charaDataManager.MaxCreatableCharaData}"); + if (_charaDataManager.OwnCharaData.Count == _charaDataManager.MaxCreatableCharaData) + { + ImGui.AlignTextToFramePadding(); + UiSharedService.ColorTextWrapped("You have reached the maximum Character Data entries and cannot create more.", ImGuiColors.DalamudYellow); + } + } + + if (_charaDataManager.DataCreationTask != null && !_charaDataManager.DataCreationTask.IsCompleted) + { + UiSharedService.ColorTextWrapped("Creating new character data entry on server...", ImGuiColors.DalamudYellow); + } + else if (_charaDataManager.DataCreationTask != null && _charaDataManager.DataCreationTask.IsCompleted) + { + var color = _charaDataManager.DataCreationTask.Result.Success ? ImGuiColors.HealerGreen : ImGuiColors.DalamudRed; + UiSharedService.ColorTextWrapped(_charaDataManager.DataCreationTask.Result.Output, color); + } + + ImGuiHelpers.ScaledDummy(10); + ImGui.Separator(); + + _ = _charaDataManager.OwnCharaData.TryGetValue(_selectedDtoId, out var dto); + DrawEditCharaData(dto); + } + + private void DrawSpecificIndividuals(CharaDataExtendedUpdateDto updateDto) + { + UiSharedService.DrawTree("Access for Specific Individuals", () => + { + ImGui.SetNextItemWidth(200); + ImGui.InputText("##AliasToAdd", ref _specificIndividualAdd, 20); + ImGui.SameLine(); + using (ImRaii.Disabled(string.IsNullOrEmpty(_specificIndividualAdd) + || updateDto.UserList.Any(f => string.Equals(f.UID, _specificIndividualAdd, StringComparison.Ordinal) || string.Equals(f.Alias, _specificIndividualAdd, StringComparison.Ordinal)))) + { + if (_uiSharedService.IconButton(FontAwesomeIcon.Plus)) + { + updateDto.AddToList(_specificIndividualAdd); + _specificIndividualAdd = string.Empty; + } + } + ImGui.SameLine(); + ImGui.TextUnformatted("UID/Vanity ID to Add"); + _uiSharedService.DrawHelpText("Users added to this list will be able to access this character data regardless of your pause or pair state with them." + UiSharedService.TooltipSeparator + + "Note: Mistyped entries will be automatically removed on updating data to server."); + + using (var lb = ImRaii.ListBox("Allowed Individuals", new(200, 200))) + { + foreach (var user in updateDto.UserList) + { + var userString = string.IsNullOrEmpty(user.Alias) ? user.UID : $"{user.Alias} ({user.UID})"; + if (ImGui.Selectable(userString, string.Equals(user.UID, _selectedSpecificIndividual, StringComparison.Ordinal))) + { + _selectedSpecificIndividual = user.UID; + } + } + } + + using (ImRaii.Disabled(string.IsNullOrEmpty(_selectedSpecificIndividual))) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Remove selected User")) + { + updateDto.RemoveFromList(_selectedSpecificIndividual); + _selectedSpecificIndividual = string.Empty; + } + } + + ImGui.Separator(); + ImGuiHelpers.ScaledDummy(5); + }); + } +} \ No newline at end of file diff --git a/MareSynchronos/UI/CharaDataHubUi.NearbyPoses.cs b/MareSynchronos/UI/CharaDataHubUi.NearbyPoses.cs new file mode 100644 index 0000000..750fc71 --- /dev/null +++ b/MareSynchronos/UI/CharaDataHubUi.NearbyPoses.cs @@ -0,0 +1,199 @@ +using Dalamud.Interface.Colors; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Interface.Utility; +using Dalamud.Interface; +using ImGuiNET; +using System.Numerics; + +namespace MareSynchronos.UI; + +internal partial class CharaDataHubUi +{ + private void DrawNearbyPoses() + { + _uiSharedService.BigText("Poses Nearby"); + + DrawHelpFoldout("This tab will show you all Shared World Poses nearby you." + Environment.NewLine + Environment.NewLine + + "Shared World Poses are poses in character data that have world data attached to them and are set to shared. " + + "This means that all data that is in 'Shared with You' that has a pose with world data attached to it will be shown here if you are nearby." + Environment.NewLine + + "By default all poses that are shared will be shown. Poses taken in housing areas will by default only be shown on the correct server and location." + Environment.NewLine + Environment.NewLine + + "Shared World Poses will appear in the world as floating wisps, as well as in the list below. You can mouse over a Shared World Pose in the list for it to get highlighted in the world." + Environment.NewLine + Environment.NewLine + + "You can apply Shared World Poses to yourself or spawn the associated character to pose with them." + Environment.NewLine + Environment.NewLine + + "You can adjust the filter and change further settings in the 'Settings & Filter' foldout."); + + UiSharedService.DrawTree("Settings & Filters", () => + { + string filterByUser = _charaDataNearbyManager.UserNoteFilter; + if (ImGui.InputTextWithHint("##filterbyuser", "Filter by User", ref filterByUser, 50)) + { + _charaDataNearbyManager.UserNoteFilter = filterByUser; + } + bool onlyCurrent = _configService.Current.NearbyOwnServerOnly; + if (ImGui.Checkbox("Only show Poses on current server", ref onlyCurrent)) + { + _configService.Current.NearbyOwnServerOnly = onlyCurrent; + _configService.Save(); + } + _uiSharedService.DrawHelpText("Toggling this off will show you the location of all shared Poses with World Data from all Servers"); + bool showOwn = _configService.Current.NearbyShowOwnData; + if (ImGui.Checkbox("Also show your own data", ref showOwn)) + { + _configService.Current.NearbyShowOwnData = showOwn; + _configService.Save(); + } + _uiSharedService.DrawHelpText("Toggling this on will also show you the location of your own Poses"); + bool ignoreHousing = _configService.Current.NearbyIgnoreHousingLimitations; + if (ImGui.Checkbox("Ignore Housing Limitations", ref ignoreHousing)) + { + _configService.Current.NearbyIgnoreHousingLimitations = ignoreHousing; + _configService.Save(); + } + _uiSharedService.DrawHelpText("This will display all poses in their location regardless of housing limitations. (Ignoring Ward, Plot, Room)" + UiSharedService.TooltipSeparator + + "Note: Poses that utilize housing props, furniture, etc. will not be displayed correctly if not spawned in the right location."); + bool showWisps = _configService.Current.NearbyDrawWisps; + if (ImGui.Checkbox("Show Pose Wisps in the overworld", ref showWisps)) + { + _configService.Current.NearbyDrawWisps = showWisps; + _configService.Save(); + } + _uiSharedService.DrawHelpText("When enabled, Mare will draw floating wisps where other's poses are in the world."); + int poseDetectionDistance = _configService.Current.NearbyDistanceFilter; + ImGui.SetNextItemWidth(100); + if (ImGui.SliderInt("Detection Distance", ref poseDetectionDistance, 5, 1000)) + { + _configService.Current.NearbyDistanceFilter = poseDetectionDistance; + _configService.Save(); + } + _uiSharedService.DrawHelpText("This setting allows you to change the maximum distance in which poses will be shown. Set it to the maximum if you want to see all poses on the current map."); + }); + + if (!_uiSharedService.IsInGpose) + { + ImGuiHelpers.ScaledDummy(5); + UiSharedService.DrawGroupedCenteredColorText("Spawning and applying pose data is only available in GPose.", ImGuiColors.DalamudYellow); + ImGuiHelpers.ScaledDummy(5); + } + + DrawUpdateSharedDataButton(); + + UiSharedService.DistanceSeparator(); + + using var child = ImRaii.Child("nearbyPosesChild", new(0, 0), false, ImGuiWindowFlags.AlwaysAutoResize); + + ImGuiHelpers.ScaledDummy(3f); + + using var indent = ImRaii.PushIndent(5f); + if (_charaDataNearbyManager.NearbyData.Count == 0) + { + UiSharedService.DrawGroupedCenteredColorText("No Shared World Poses found nearby.", ImGuiColors.DalamudYellow); + } + + bool wasAnythingHovered = false; + int i = 0; + foreach (var pose in _charaDataNearbyManager.NearbyData.OrderBy(v => v.Value.Distance)) + { + using var poseId = ImRaii.PushId("nearbyPose" + (i++)); + var pos = ImGui.GetCursorPos(); + var circleDiameter = 60f; + var circleOriginX = ImGui.GetWindowContentRegionMax().X - circleDiameter - pos.X; + float circleOffsetY = 0; + + UiSharedService.DrawGrouped(() => + { + string? userNote = _serverConfigurationManager.GetNoteForUid(pose.Key.MetaInfo.Uploader.UID); + var noteText = pose.Key.MetaInfo.IsOwnData ? "YOU" : (userNote == null ? pose.Key.MetaInfo.Uploader.AliasOrUID : $"{userNote} ({pose.Key.MetaInfo.Uploader.AliasOrUID})"); + ImGui.TextUnformatted("Pose by"); + ImGui.SameLine(); + UiSharedService.ColorText(noteText, ImGuiColors.ParsedGreen); + using (ImRaii.Group()) + { + UiSharedService.ColorText("Character Data Description", ImGuiColors.DalamudGrey); + ImGui.SameLine(); + _uiSharedService.IconText(FontAwesomeIcon.ExternalLinkAlt, ImGuiColors.DalamudGrey); + } + UiSharedService.AttachToolTip(pose.Key.MetaInfo.Description); + UiSharedService.ColorText("Description", ImGuiColors.DalamudGrey); + ImGui.SameLine(); + UiSharedService.TextWrapped(pose.Key.Description ?? "No Pose Description was set", circleOriginX); + var posAfterGroup = ImGui.GetCursorPos(); + var groupHeightCenter = (posAfterGroup.Y - pos.Y) / 2; + circleOffsetY = (groupHeightCenter - circleDiameter / 2); + if (circleOffsetY < 0) circleOffsetY = 0; + ImGui.SetCursorPos(new Vector2(circleOriginX, pos.Y)); + ImGui.Dummy(new Vector2(circleDiameter, circleDiameter)); + UiSharedService.AttachToolTip("Click to open corresponding map and set map marker" + UiSharedService.TooltipSeparator + + pose.Key.WorldDataDescriptor); + if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) + { + _dalamudUtilService.SetMarkerAndOpenMap(pose.Key.Position, pose.Key.Map); + } + ImGui.SetCursorPos(posAfterGroup); + if (_uiSharedService.IsInGpose) + { + GposePoseAction(() => + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowRight, "Apply Pose")) + { + _charaDataManager.ApplyFullPoseDataToGposeTarget(pose.Key); + } + }, $"Apply pose and position to {CharaName(_gposeTarget)}", _hasValidGposeTarget); + ImGui.SameLine(); + GposeMetaInfoAction((_) => + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Plus, "Spawn and Pose")) + { + _charaDataManager.SpawnAndApplyWorldTransform(pose.Key.MetaInfo, pose.Key); + } + }, "Spawn actor and apply pose and position", pose.Key.MetaInfo, _hasValidGposeTarget, true); + } + }); + if (ImGui.IsItemHovered()) + { + wasAnythingHovered = true; + _nearbyHovered = pose.Key; + } + var drawList = ImGui.GetWindowDrawList(); + var circleRadius = circleDiameter / 2f; + var windowPos = ImGui.GetWindowPos(); + var scrollX = ImGui.GetScrollX(); + var scrollY = ImGui.GetScrollY(); + var circleCenter = new Vector2(windowPos.X + circleOriginX + circleRadius - scrollX, windowPos.Y + pos.Y + circleRadius + circleOffsetY - scrollY); + var rads = pose.Value.Direction * (Math.PI / 180); + + float halfConeAngleRadians = 15f * (float)Math.PI / 180f; + Vector2 baseDir1 = new Vector2((float)Math.Sin(rads - halfConeAngleRadians), -(float)Math.Cos(rads - halfConeAngleRadians)); + Vector2 baseDir2 = new Vector2((float)Math.Sin(rads + halfConeAngleRadians), -(float)Math.Cos(rads + halfConeAngleRadians)); + + Vector2 coneBase1 = circleCenter + baseDir1 * circleRadius; + Vector2 coneBase2 = circleCenter + baseDir2 * circleRadius; + + // Draw the cone as a filled triangle + drawList.AddTriangleFilled(circleCenter, coneBase1, coneBase2, UiSharedService.Color(ImGuiColors.ParsedGreen)); + drawList.AddCircle(circleCenter, circleDiameter / 2, UiSharedService.Color(ImGuiColors.DalamudWhite), 360, 2); + var distance = pose.Value.Distance.ToString("0.0") + "y"; + var textSize = ImGui.CalcTextSize(distance); + drawList.AddText(new Vector2(circleCenter.X - textSize.X / 2, circleCenter.Y + textSize.Y / 3f), UiSharedService.Color(ImGuiColors.DalamudWhite), distance); + + ImGuiHelpers.ScaledDummy(3); + } + + if (!wasAnythingHovered) _nearbyHovered = null; + _charaDataNearbyManager.SetHoveredVfx(_nearbyHovered); + } + + private void DrawUpdateSharedDataButton() + { + using (ImRaii.Disabled(_charaDataManager.GetAllDataTask != null + || (_charaDataManager.GetSharedWithYouTimeoutTask != null && !_charaDataManager.GetSharedWithYouTimeoutTask.IsCompleted))) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowCircleDown, "Update Data Shared With You")) + { + _ = _charaDataManager.GetAllSharedData(_disposalCts.Token).ContinueWith(u => UpdateFilteredItems()); + } + } + if (_charaDataManager.GetSharedWithYouTimeoutTask != null && !_charaDataManager.GetSharedWithYouTimeoutTask.IsCompleted) + { + UiSharedService.AttachToolTip("You can only refresh all character data from server every minute. Please wait."); + } + } +} diff --git a/MareSynchronos/UI/CharaDataHubUi.cs b/MareSynchronos/UI/CharaDataHubUi.cs new file mode 100644 index 0000000..37412cb --- /dev/null +++ b/MareSynchronos/UI/CharaDataHubUi.cs @@ -0,0 +1,1043 @@ +using Dalamud.Interface; +using Dalamud.Interface.Colors; +using Dalamud.Interface.ImGuiFileDialog; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using ImGuiNET; +using MareSynchronos.API.Dto.CharaData; +using MareSynchronos.MareConfiguration; +using MareSynchronos.MareConfiguration.Models; +using MareSynchronos.Services; +using MareSynchronos.Services.CharaData.Models; +using MareSynchronos.Services.Mediator; +using MareSynchronos.Services.ServerConfiguration; +using MareSynchronos.Utils; +using Microsoft.Extensions.Logging; + +namespace MareSynchronos.UI; + +internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase +{ + private const int maxPoses = 10; + private readonly CharaDataManager _charaDataManager; + private readonly CharaDataNearbyManager _charaDataNearbyManager; + private readonly CharaDataConfigService _configService; + private readonly DalamudUtilService _dalamudUtilService; + private readonly FileDialogManager _fileDialogManager; + private readonly ServerConfigurationManager _serverConfigurationManager; + private readonly UiSharedService _uiSharedService; + private CancellationTokenSource _closalCts = new(); + private bool _disableUI = false; + private CancellationTokenSource _disposalCts = new(); + private string _exportDescription = string.Empty; + private string _filterCodeNote = string.Empty; + private string _filterDescription = string.Empty; + private Dictionary>? _filteredDict; + private Dictionary _filteredFavorites = []; + private bool _filterPoseOnly = false; + private bool _filterWorldOnly = false; + private string _gposeTarget = string.Empty; + private bool _hasValidGposeTarget; + private string _importCode = string.Empty; + private bool _isHandlingSelf = false; + private DateTime _lastFavoriteUpdateTime = DateTime.UtcNow; + private PoseEntryExtended? _nearbyHovered; + private bool _openMcdOnlineOnNextRun = false; + private bool _readExport; + private string _selectedDtoId = string.Empty; + private string _selectedSpecificIndividual = string.Empty; + private string _sharedWithYouDescriptionFilter = string.Empty; + private bool _sharedWithYouDownloadableFilter = false; + private string _sharedWithYouOwnerFilter = string.Empty; + private string _specificIndividualAdd = string.Empty; + private bool _abbreviateCharaName = false; + + public CharaDataHubUi(ILogger logger, MareMediator mediator, PerformanceCollectorService performanceCollectorService, + CharaDataManager charaDataManager, CharaDataNearbyManager charaDataNearbyManager, CharaDataConfigService configService, + UiSharedService uiSharedService, ServerConfigurationManager serverConfigurationManager, + DalamudUtilService dalamudUtilService, FileDialogManager fileDialogManager) + : base(logger, mediator, "Mare Synchronos Character Data Hub###MareSynchronosCharaDataUI", performanceCollectorService) + { + SetWindowSizeConstraints(); + + _charaDataManager = charaDataManager; + _charaDataNearbyManager = charaDataNearbyManager; + _configService = configService; + _uiSharedService = uiSharedService; + _serverConfigurationManager = serverConfigurationManager; + _dalamudUtilService = dalamudUtilService; + _fileDialogManager = fileDialogManager; + Mediator.Subscribe(this, (_) => IsOpen |= _configService.Current.OpenMareHubOnGposeStart); + } + + public string CharaName(string name) + { + if (_abbreviateCharaName) + { + var split = name.Split(" "); + return split[0].First() + ". " + split[1].First() + "."; + } + + return name; + } + + public override void OnClose() + { + if (_disableUI) + { + IsOpen = true; + return; + } + + _closalCts.Cancel(); + _selectedDtoId = string.Empty; + _filteredDict = null; + _sharedWithYouOwnerFilter = string.Empty; + _importCode = string.Empty; + _charaDataNearbyManager.ComputeNearbyData = false; + } + + public override void OnOpen() + { + _closalCts = _closalCts.CancelRecreate(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _closalCts.CancelDispose(); + _disposalCts.CancelDispose(); + } + + base.Dispose(disposing); + } + + protected override void DrawInternal() + { + _disableUI = !(_charaDataManager.UiBlockingComputation?.IsCompleted ?? true); + if (DateTime.UtcNow.Subtract(_lastFavoriteUpdateTime).TotalSeconds > 2) + { + _lastFavoriteUpdateTime = DateTime.UtcNow; + UpdateFilteredFavorites(); + } + + _hasValidGposeTarget = _charaDataManager.CanApplyInGpose(out _gposeTarget); + + if (!_charaDataManager.BrioAvailable) + { + ImGuiHelpers.ScaledDummy(3); + UiSharedService.DrawGroupedCenteredColorText("To utilize any features related to posing or spawning characters you require to have Brio installed.", ImGuiColors.DalamudRed); + UiSharedService.DistanceSeparator(); + } + + using var disabled = ImRaii.Disabled(_disableUI); + + DisableDisabled(() => + { + if (_charaDataManager.DataApplicationTask != null) + { + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Applying Data to Actor"); + ImGui.SameLine(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Ban, "Cancel Application")) + { + _charaDataManager.CancelDataApplication(); + } + } + if (!string.IsNullOrEmpty(_charaDataManager.DataApplicationProgress)) + { + UiSharedService.ColorTextWrapped(_charaDataManager.DataApplicationProgress, ImGuiColors.DalamudYellow); + } + if (_charaDataManager.DataApplicationTask != null) + { + UiSharedService.ColorTextWrapped("WARNING: During the data application avoid interacting with this actor to prevent potential crashes.", ImGuiColors.DalamudRed); + ImGuiHelpers.ScaledDummy(5); + ImGui.Separator(); + } + }); + + using var tabs = ImRaii.TabBar("TabsTopLevel"); + bool smallUi = false; + + _isHandlingSelf = _charaDataManager.HandledCharaData.Any(c => c.IsSelf); + if (_isHandlingSelf) _openMcdOnlineOnNextRun = false; + + using (var applicationTabItem = ImRaii.TabItem("Data Application")) + { + if (applicationTabItem) + { + smallUi = true; + using var appTabs = ImRaii.TabBar("TabsApplicationLevel"); + + using (ImRaii.Disabled(!_uiSharedService.IsInGpose)) + { + using (var gposeTabItem = ImRaii.TabItem("GPose Actors")) + { + if (gposeTabItem) + { + using var id = ImRaii.PushId("gposeControls"); + DrawGposeControls(); + } + } + } + if (!_uiSharedService.IsInGpose) + UiSharedService.AttachToolTip("Only available in GPose"); + + using (var nearbyPosesTabItem = ImRaii.TabItem("Poses Nearby")) + { + if (nearbyPosesTabItem) + { + using var id = ImRaii.PushId("nearbyPoseControls"); + _charaDataNearbyManager.ComputeNearbyData = true; + + DrawNearbyPoses(); + } + else + { + _charaDataNearbyManager.ComputeNearbyData = false; + } + } + + using (var gposeTabItem = ImRaii.TabItem("Apply Data")) + { + if (gposeTabItem) + { + smallUi |= true; + using var id = ImRaii.PushId("applyData"); + DrawDataApplication(); + } + } + } + else + { + _charaDataNearbyManager.ComputeNearbyData = false; + } + } + + using (ImRaii.Disabled(_isHandlingSelf)) + { + ImGuiTabItemFlags flagsTopLevel = ImGuiTabItemFlags.None; + if (_openMcdOnlineOnNextRun) + { + flagsTopLevel = ImGuiTabItemFlags.SetSelected; + _openMcdOnlineOnNextRun = false; + } + + using (var creationTabItem = ImRaii.TabItem("Data Creation", flagsTopLevel)) + { + if (creationTabItem) + { + using var creationTabs = ImRaii.TabBar("TabsCreationLevel"); + + ImGuiTabItemFlags flags = ImGuiTabItemFlags.None; + if (_openMcdOnlineOnNextRun) + { + flags = ImGuiTabItemFlags.SetSelected; + _openMcdOnlineOnNextRun = false; + } + using (var mcdOnlineTabItem = ImRaii.TabItem("MCD Online", flags)) + { + if (mcdOnlineTabItem) + { + using var id = ImRaii.PushId("mcdOnline"); + DrawMcdOnline(); + } + } + + using (var mcdfTabItem = ImRaii.TabItem("MCDF Export")) + { + if (mcdfTabItem) + { + using var id = ImRaii.PushId("mcdfExport"); + DrawMcdfExport(); + } + } + } + } + } + if (_isHandlingSelf) + { + UiSharedService.AttachToolTip("Cannot use creation tools while having Character Data applied to self."); + } + + using (var settingsTabItem = ImRaii.TabItem("Settings")) + { + if (settingsTabItem) + { + using var id = ImRaii.PushId("settings"); + DrawSettings(); + } + } + + + SetWindowSizeConstraints(smallUi); + } + + private void DrawAddOrRemoveFavorite(CharaDataFullDto dto) + { + DrawFavorite(dto.Uploader.UID + ":" + dto.Id); + } + + private void DrawAddOrRemoveFavorite(CharaDataMetaInfoExtendedDto? dto) + { + if (dto == null) return; + DrawFavorite(dto.FullId); + } + + private void DrawFavorite(string id) + { + bool isFavorite = _configService.Current.FavoriteCodes.TryGetValue(id, out var favorite); + if (_configService.Current.FavoriteCodes.ContainsKey(id)) + { + _uiSharedService.IconText(FontAwesomeIcon.Star, ImGuiColors.ParsedGold); + UiSharedService.AttachToolTip($"Custom Description: {favorite?.CustomDescription ?? string.Empty}" + UiSharedService.TooltipSeparator + + "Click to remove from Favorites"); + } + else + { + _uiSharedService.IconText(FontAwesomeIcon.Star, ImGuiColors.DalamudGrey); + UiSharedService.AttachToolTip("Click to add to Favorites"); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) + { + if (isFavorite) _configService.Current.FavoriteCodes.Remove(id); + else _configService.Current.FavoriteCodes[id] = new(); + _configService.Save(); + } + } + + private void DrawGposeControls() + { + _uiSharedService.BigText("GPose Actors"); + ImGuiHelpers.ScaledDummy(5); + using var indent = ImRaii.PushIndent(10f); + + foreach (var actor in _dalamudUtilService.GetGposeCharactersFromObjectTable()) + { + if (actor == null) continue; + using var actorId = ImRaii.PushId(actor.Name.TextValue); + UiSharedService.DrawGrouped(() => + { + if (_uiSharedService.IconButton(FontAwesomeIcon.Crosshairs)) + { + unsafe + { + _dalamudUtilService.GposeTarget = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)actor.Address; + } + } + ImGui.SameLine(); + UiSharedService.AttachToolTip($"Target the GPose Character {CharaName(actor.Name.TextValue)}"); + ImGui.AlignTextToFramePadding(); + var pos = ImGui.GetCursorPosX(); + using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.HealerGreen, actor.Address == (_dalamudUtilService.GposeTargetGameObject?.Address ?? nint.Zero))) + { + ImGui.TextUnformatted(CharaName(actor.Name.TextValue)); + } + ImGui.SameLine(250); + var handled = _charaDataManager.HandledCharaData.FirstOrDefault(c => string.Equals(c.Name, actor.Name.TextValue, StringComparison.Ordinal)); + using (ImRaii.Disabled(handled == null)) + { + _uiSharedService.IconText(FontAwesomeIcon.InfoCircle); + var id = string.IsNullOrEmpty(handled?.MetaInfo.Uploader.UID) ? handled?.MetaInfo.Id : handled.MetaInfo.FullId; + UiSharedService.AttachToolTip($"Applied Data: {id ?? "No data applied"}"); + + ImGui.SameLine(); + // maybe do this better, check with brio for handled charas or sth + using (ImRaii.Disabled(!actor.Name.TextValue.StartsWith("Brio ", StringComparison.Ordinal))) + { + if (_uiSharedService.IconButton(FontAwesomeIcon.Trash)) + { + _charaDataManager.RemoveChara(actor.Name.TextValue); + } + UiSharedService.AttachToolTip($"Remove character {CharaName(actor.Name.TextValue)}"); + } + ImGui.SameLine(); + if (_uiSharedService.IconButton(FontAwesomeIcon.Undo)) + { + _charaDataManager.RevertChara(handled); + } + UiSharedService.AttachToolTip($"Revert applied data from {CharaName(actor.Name.TextValue)}"); + ImGui.SetCursorPosX(pos); + DrawPoseData(handled?.MetaInfo, actor.Name.TextValue, true); + } + }); + + ImGuiHelpers.ScaledDummy(2); + } + } + + private void DrawDataApplication() + { + _uiSharedService.BigText("Apply Character Appearance"); + + ImGuiHelpers.ScaledDummy(5); + + if (_uiSharedService.IsInGpose) + { + ImGui.TextUnformatted("GPose Target"); + ImGui.SameLine(200); + UiSharedService.ColorText(CharaName(_gposeTarget), UiSharedService.GetBoolColor(_hasValidGposeTarget)); + } + + if (!_hasValidGposeTarget) + { + ImGuiHelpers.ScaledDummy(3); + UiSharedService.DrawGroupedCenteredColorText("Applying data is only available in GPose with a valid selected GPose target.", ImGuiColors.DalamudYellow, 350); + } + + ImGuiHelpers.ScaledDummy(10); + + using var tabs = ImRaii.TabBar("Tabs"); + + using (var byFavoriteTabItem = ImRaii.TabItem("Favorites")) + { + if (byFavoriteTabItem) + { + using var id = ImRaii.PushId("byFavorite"); + + ImGuiHelpers.ScaledDummy(5); + + var max = ImGui.GetWindowContentRegionMax(); + UiSharedService.DrawTree("Filters", () => + { + var maxIndent = ImGui.GetWindowContentRegionMax(); + ImGui.SetNextItemWidth(maxIndent.X - ImGui.GetCursorPosX()); + ImGui.InputTextWithHint("##ownFilter", "Code/Owner Filter", ref _filterCodeNote, 100); + ImGui.SetNextItemWidth(maxIndent.X - ImGui.GetCursorPosX()); + ImGui.InputTextWithHint("##descFilter", "Custom Description Filter", ref _filterDescription, 100); + ImGui.Checkbox("Only show entries with pose data", ref _filterPoseOnly); + ImGui.Checkbox("Only show entries with world data", ref _filterWorldOnly); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Ban, "Reset Filter")) + { + _filterCodeNote = string.Empty; + _filterDescription = string.Empty; + _filterPoseOnly = false; + _filterWorldOnly = false; + } + }); + + ImGuiHelpers.ScaledDummy(5); + ImGui.Separator(); + using var scrollableChild = ImRaii.Child("favorite"); + ImGuiHelpers.ScaledDummy(5); + using var totalIndent = ImRaii.PushIndent(5f); + var cursorPos = ImGui.GetCursorPos(); + max = ImGui.GetWindowContentRegionMax(); + foreach (var favorite in _filteredFavorites.OrderByDescending(k => k.Value.Favorite.LastDownloaded)) + { + UiSharedService.DrawGrouped(() => + { + using var tableid = ImRaii.PushId(favorite.Key); + ImGui.AlignTextToFramePadding(); + DrawFavorite(favorite.Key); + using var innerIndent = ImRaii.PushIndent(25f); + ImGui.SameLine(); + var xPos = ImGui.GetCursorPosX(); + var maxPos = (max.X - cursorPos.X); + + bool metaInfoDownloaded = favorite.Value.DownloadedMetaInfo; + var metaInfo = favorite.Value.MetaInfo; + + ImGui.AlignTextToFramePadding(); + using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudGrey, !metaInfoDownloaded)) + using (ImRaii.PushColor(ImGuiCol.Text, UiSharedService.GetBoolColor(metaInfo != null), metaInfoDownloaded)) + ImGui.TextUnformatted(favorite.Key); + + var iconSize = _uiSharedService.GetIconData(FontAwesomeIcon.Check); + var refreshButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.ArrowsSpin); + var applyButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.ArrowRight); + var addButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Plus); + var offsetFromRight = maxPos - (iconSize.X + refreshButtonSize.X + applyButtonSize.X + addButtonSize.X + (ImGui.GetStyle().ItemSpacing.X * 3.5f)); + + ImGui.SameLine(); + ImGui.SetCursorPosX(offsetFromRight); + if (metaInfoDownloaded) + { + _uiSharedService.BooleanToColoredIcon(metaInfo != null, false); + if (metaInfo != null) + { + UiSharedService.AttachToolTip("Metainfo present" + UiSharedService.TooltipSeparator + + $"Last Updated: {metaInfo!.UpdatedDate}" + Environment.NewLine + + $"Description: {metaInfo!.Description}" + Environment.NewLine + + $"Poses: {metaInfo!.PoseData.Count}"); + } + else + { + UiSharedService.AttachToolTip("Metainfo could not be downloaded." + UiSharedService.TooltipSeparator + + "The data associated with the code is either not present on the server anymore or you have no access to it"); + } + } + else + { + _uiSharedService.IconText(FontAwesomeIcon.QuestionCircle, ImGuiColors.DalamudGrey); + UiSharedService.AttachToolTip("Unknown accessibility state. Click the button on the right to refresh."); + } + + ImGui.SameLine(); + bool isInTimeout = _charaDataManager.IsInTimeout(favorite.Key); + using (ImRaii.Disabled(isInTimeout)) + { + if (_uiSharedService.IconButton(FontAwesomeIcon.ArrowsSpin)) + { + _charaDataManager.DownloadMetaInfo(favorite.Key, false); + UpdateFilteredItems(); + } + } + UiSharedService.AttachToolTip(isInTimeout ? "Timeout for refreshing active, please wait before refreshing again." + : "Refresh data for this entry from the Server."); + + ImGui.SameLine(); + GposeMetaInfoAction((meta) => + { + if (_uiSharedService.IconButton(FontAwesomeIcon.ArrowRight)) + { + _ = _charaDataManager.ApplyCharaDataToGposeTarget(metaInfo!); + } + }, "Apply Character Data to GPose Target", metaInfo, _hasValidGposeTarget, false); + ImGui.SameLine(); + GposeMetaInfoAction((meta) => + { + if (_uiSharedService.IconButton(FontAwesomeIcon.Plus)) + { + _ = _charaDataManager.SpawnAndApplyData(meta!); + } + }, "Spawn Actor with Brio and apply Character Data", metaInfo, _hasValidGposeTarget, true); + + string uidText = string.Empty; + var uid = favorite.Key.Split(":")[0]; + if (metaInfo != null) + { + uidText = metaInfo.Uploader.AliasOrUID; + } + else + { + uidText = uid; + } + + var note = _serverConfigurationManager.GetNoteForUid(uid); + if (note != null) + { + uidText = $"{note} ({uidText})"; + } + ImGui.TextUnformatted(uidText); + + ImGui.TextUnformatted("Last Use: "); + ImGui.SameLine(); + ImGui.TextUnformatted(favorite.Value.Favorite.LastDownloaded == DateTime.MaxValue ? "Never" : favorite.Value.Favorite.LastDownloaded.ToString()); + + var desc = favorite.Value.Favorite.CustomDescription; + ImGui.SetNextItemWidth(maxPos - xPos); + if (ImGui.InputTextWithHint("##desc", "Custom Description for Favorite", ref desc, 100)) + { + favorite.Value.Favorite.CustomDescription = desc; + _configService.Save(); + } + + DrawPoseData(metaInfo, _gposeTarget, _hasValidGposeTarget); + }); + + ImGuiHelpers.ScaledDummy(5); + } + + if (_configService.Current.FavoriteCodes.Count == 0) + { + UiSharedService.ColorTextWrapped("You have no favorites added. Add Favorites through the other tabs before you can use this tab.", ImGuiColors.DalamudYellow); + } + } + } + + using (var byCodeTabItem = ImRaii.TabItem("Code")) + { + using var id = ImRaii.PushId("byCodeTab"); + if (byCodeTabItem) + { + using var child = ImRaii.Child("sharedWithYouByCode", new(0, 0), false, ImGuiWindowFlags.AlwaysAutoResize); + DrawHelpFoldout("You can apply character data you have a code for in this tab. Provide the code in it's given format \"OwnerUID:DataId\" into the field below and click on " + + "\"Get Info from Code\". This will provide you basic information about the data behind the code. Afterwards select an actor in GPose and press on \"Download and apply to \"." + Environment.NewLine + Environment.NewLine + + "Description: as set by the owner of the code to give you more or additional information of what this code may contain." + Environment.NewLine + + "Last Update: the date and time the owner of the code has last updated the data." + Environment.NewLine + + "Is Downloadable: whether or not the code is downloadable and applicable. If the code is not downloadable, contact the owner so they can attempt to fix it." + Environment.NewLine + Environment.NewLine + + "To download a code the code requires correct access permissions to be set by the owner. If getting info from the code fails, contact the owner to make sure they set their Access Permissions for the code correctly."); + + ImGuiHelpers.ScaledDummy(5); + ImGui.InputTextWithHint("##importCode", "Enter Data Code", ref _importCode, 100); + using (ImRaii.Disabled(string.IsNullOrEmpty(_importCode))) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowCircleDown, "Get Info from Code")) + { + _charaDataManager.DownloadMetaInfo(_importCode); + } + } + GposeMetaInfoAction((meta) => + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowRight, $"Download and Apply")) + { + _ = _charaDataManager.ApplyCharaDataToGposeTarget(meta!); + } + }, "Apply this Character Data to the current GPose actor", _charaDataManager.LastDownloadedMetaInfo, _hasValidGposeTarget, false); + ImGui.SameLine(); + GposeMetaInfoAction((meta) => + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Plus, $"Download and Spawn")) + { + _ = _charaDataManager.SpawnAndApplyData(meta!); + } + }, "Spawn a new Brio actor and apply this Character Data", _charaDataManager.LastDownloadedMetaInfo, _hasValidGposeTarget, true); + ImGui.SameLine(); + ImGui.AlignTextToFramePadding(); + DrawAddOrRemoveFavorite(_charaDataManager.LastDownloadedMetaInfo); + + if (!_charaDataManager.DownloadMetaInfoTask?.IsCompleted ?? false) + { + UiSharedService.ColorTextWrapped("Downloading meta info. Please wait.", ImGuiColors.DalamudYellow); + } + if ((_charaDataManager.DownloadMetaInfoTask?.IsCompleted ?? false) && !_charaDataManager.DownloadMetaInfoTask.Result.Success) + { + UiSharedService.ColorTextWrapped(_charaDataManager.DownloadMetaInfoTask.Result.Result, ImGuiColors.DalamudRed); + } + + using (ImRaii.Disabled(_charaDataManager.LastDownloadedMetaInfo == null)) + { + ImGuiHelpers.ScaledDummy(5); + var metaInfo = _charaDataManager.LastDownloadedMetaInfo; + ImGui.TextUnformatted("Description"); + ImGui.SameLine(150); + UiSharedService.TextWrapped(string.IsNullOrEmpty(metaInfo?.Description) ? "-" : metaInfo.Description); + ImGui.TextUnformatted("Last Update"); + ImGui.SameLine(150); + ImGui.TextUnformatted(metaInfo?.UpdatedDate.ToLocalTime().ToString() ?? "-"); + ImGui.TextUnformatted("Is Downloadable"); + ImGui.SameLine(150); + _uiSharedService.BooleanToColoredIcon(metaInfo?.CanBeDownloaded ?? false, inline: false); + ImGui.TextUnformatted("Poses"); + ImGui.SameLine(150); + if (metaInfo?.HasPoses ?? false) + DrawPoseData(metaInfo, _gposeTarget, _hasValidGposeTarget); + else + _uiSharedService.BooleanToColoredIcon(false, false); + } + } + } + + using (var yourOwnTabItem = ImRaii.TabItem("Your Own")) + { + using var id = ImRaii.PushId("yourOwnTab"); + if (yourOwnTabItem) + { + DrawHelpFoldout("You can apply character data you created yourself in this tab. If the list is not populated press on \"Download your Character Data\"." + Environment.NewLine + Environment.NewLine + + "To create new and edit your existing character data use the \"MCD Online\" tab."); + + ImGuiHelpers.ScaledDummy(5); + + using (ImRaii.Disabled(_charaDataManager.GetAllDataTask != null + || (_charaDataManager.DataGetTimeoutTask != null && !_charaDataManager.DataGetTimeoutTask.IsCompleted))) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowCircleDown, "Download your Character Data")) + { + _ = _charaDataManager.GetAllData(_disposalCts.Token); + } + } + if (_charaDataManager.DataGetTimeoutTask != null && !_charaDataManager.DataGetTimeoutTask.IsCompleted) + { + UiSharedService.AttachToolTip("You can only refresh all character data from server every minute. Please wait."); + } + + ImGuiHelpers.ScaledDummy(5); + ImGui.Separator(); + + using var child = ImRaii.Child("ownDataChild", new(0, 0), false, ImGuiWindowFlags.AlwaysAutoResize); + using var indent = ImRaii.PushIndent(10f); + foreach (var data in _charaDataManager.OwnCharaData.Values) + { + var hasMetaInfo = _charaDataManager.TryGetMetaInfo(data.FullId, out var metaInfo); + if (!hasMetaInfo) continue; + DrawMetaInfoData(_gposeTarget, _hasValidGposeTarget, metaInfo!, true); + } + + ImGuiHelpers.ScaledDummy(5); + } + } + + using (var sharedWithYouTabItem = ImRaii.TabItem("Shared With You")) + { + using var id = ImRaii.PushId("sharedWithYouTab"); + if (sharedWithYouTabItem) + { + DrawHelpFoldout("You can apply character data shared with you implicitly in this tab. Shared Character Data are Character Data entries that have \"Sharing\" set to \"Shared\" and you have access through those by meeting the access restrictions, " + + "i.e. you were specified by your UID to gain access or are paired with the other user according to the Access Restrictions setting." + Environment.NewLine + Environment.NewLine + + "Filter if needed to find a specific entry, then just press on \"Apply to \" and it will download and apply the Character Data to the currently targeted GPose actor."); + + ImGuiHelpers.ScaledDummy(5); + + DrawUpdateSharedDataButton(); + + + UiSharedService.DrawTree("Filters", () => + { + var filterWidth = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X; + ImGui.SetNextItemWidth(filterWidth); + if (ImGui.InputTextWithHint("##filter", "Filter by UID/Note", ref _sharedWithYouOwnerFilter, 30)) + { + UpdateFilteredItems(); + } + ImGui.SetNextItemWidth(filterWidth); + if (ImGui.InputTextWithHint("##filterDesc", "Filter by Description", ref _sharedWithYouDescriptionFilter, 50)) + { + UpdateFilteredItems(); + } + if (ImGui.Checkbox("Only show downloadable", ref _sharedWithYouDownloadableFilter)) + { + UpdateFilteredItems(); + } + }); + + if (_filteredDict == null && _charaDataManager.GetSharedWithYouTask == null) + { + _filteredDict = _charaDataManager.SharedWithYouData + .ToDictionary(k => + { + var note = _serverConfigurationManager.GetNoteForUid(k.Key.UID); + if (note == null) return k.Key.AliasOrUID; + return $"{note} ({k.Key.AliasOrUID})"; + }, k => k.Value, StringComparer.OrdinalIgnoreCase) + .Where(k => string.IsNullOrEmpty(_sharedWithYouOwnerFilter) || k.Key.Contains(_sharedWithYouOwnerFilter)) + .OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase).ToDictionary(); + } + + ImGuiHelpers.ScaledDummy(5); + ImGui.Separator(); + using var child = ImRaii.Child("sharedWithYouChild", new(0, 0), false, ImGuiWindowFlags.AlwaysAutoResize); + + ImGuiHelpers.ScaledDummy(5); + foreach (var entry in _filteredDict ?? []) + { + UiSharedService.DrawTree($"{entry.Key} - [{entry.Value.Count} Character Data Sets]##{entry.Key}", () => + { + foreach (var data in entry.Value) + { + DrawMetaInfoData(_gposeTarget, _hasValidGposeTarget, data); + } + ImGuiHelpers.ScaledDummy(5); + }); + } + } + } + + using (var mcdfTabItem = ImRaii.TabItem("From MCDF")) + { + using var id = ImRaii.PushId("applyMcdfTab"); + if (mcdfTabItem) + { + using var child = ImRaii.Child("applyMcdf", new(0, 0), false, ImGuiWindowFlags.AlwaysAutoResize); + DrawHelpFoldout("You can apply character data shared with you using a MCDF file in this tab." + Environment.NewLine + Environment.NewLine + + "Load the MCDF first via the \"Load MCDF\" button which will give you the basic description that the owner has set during export." + Environment.NewLine + + "You can then apply it to any handled GPose actor." + Environment.NewLine + Environment.NewLine + + "MCDF to share with others can be generated using the \"MCDF Export\" tab at the top."); + + ImGuiHelpers.ScaledDummy(5); + + if (_charaDataManager.LoadedMcdfHeader == null || _charaDataManager.LoadedMcdfHeader.IsCompleted) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.FolderOpen, "Load MCDF")) + { + _fileDialogManager.OpenFileDialog("Pick MCDF file", ".mcdf", (success, paths) => + { + if (!success) return; + if (paths.FirstOrDefault() is not string path) return; + + _configService.Current.LastSavedCharaDataLocation = Path.GetDirectoryName(path) ?? string.Empty; + _configService.Save(); + + _charaDataManager.LoadMcdf(path); + }, 1, Directory.Exists(_configService.Current.LastSavedCharaDataLocation) ? _configService.Current.LastSavedCharaDataLocation : null); + } + UiSharedService.AttachToolTip("Load MCDF Metadata into memory"); + if ((_charaDataManager.LoadedMcdfHeader?.IsCompleted ?? false)) + { + ImGui.TextUnformatted("Loaded file"); + ImGui.SameLine(200); + UiSharedService.TextWrapped(_charaDataManager.LoadedMcdfHeader.Result.LoadedFile.FilePath); + ImGui.Text("Description"); + ImGui.SameLine(200); + UiSharedService.TextWrapped(_charaDataManager.LoadedMcdfHeader.Result.LoadedFile.CharaFileData.Description); + + ImGuiHelpers.ScaledDummy(5); + + using (ImRaii.Disabled(!_hasValidGposeTarget)) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowRight, "Apply")) + { + _charaDataManager.McdfApplyToGposeTarget(); + } + UiSharedService.AttachToolTip($"Apply to {_gposeTarget}"); + ImGui.SameLine(); + using (ImRaii.Disabled(!_charaDataManager.BrioAvailable)) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Plus, "Spawn Actor and Apply")) + { + _charaDataManager.McdfSpawnApplyToGposeTarget(); + } + } + } + } + if ((_charaDataManager.LoadedMcdfHeader?.IsFaulted ?? false) || (_charaDataManager.McdfApplicationTask?.IsFaulted ?? false)) + { + UiSharedService.ColorTextWrapped("Failure to read MCDF file. MCDF file is possibly corrupt. Re-export the MCDF file and try again.", + ImGuiColors.DalamudRed); + UiSharedService.ColorTextWrapped("Note: if this is your MCDF, try redrawing yourself, wait and re-export the file. " + + "If you received it from someone else have them do the same.", ImGuiColors.DalamudYellow); + } + } + else + { + UiSharedService.ColorTextWrapped("Loading Character...", ImGuiColors.DalamudYellow); + } + } + } + } + + private void DrawMcdfExport() + { + _uiSharedService.BigText("Mare Character Data File Export"); + + DrawHelpFoldout("This feature allows you to pack your character into a MCDF file and manually send it to other people. MCDF files can officially only be imported during GPose through Mare. " + + "Be aware that the possibility exists that people write unofficial custom exporters to extract the containing data."); + + ImGuiHelpers.ScaledDummy(5); + + ImGui.Checkbox("##readExport", ref _readExport); + ImGui.SameLine(); + UiSharedService.TextWrapped("I understand that by exporting my character data into a file and sending it to other people I am giving away my current character appearance irrevocably. People I am sharing my data with have the ability to share it with other people without limitations."); + + if (_readExport) + { + ImGui.Indent(); + + ImGui.InputTextWithHint("Export Descriptor", "This description will be shown on loading the data", ref _exportDescription, 255); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Export Character as MCDF")) + { + string defaultFileName = string.IsNullOrEmpty(_exportDescription) + ? "export.mcdf" + : string.Join('_', $"{_exportDescription}.mcdf".Split(Path.GetInvalidFileNameChars())); + _uiSharedService.FileDialogManager.SaveFileDialog("Export Character to file", ".mcdf", defaultFileName, ".mcdf", (success, path) => + { + if (!success) return; + + _configService.Current.LastSavedCharaDataLocation = Path.GetDirectoryName(path) ?? string.Empty; + _configService.Save(); + + _charaDataManager.SaveMareCharaFile(_exportDescription, path); + _exportDescription = string.Empty; + }, Directory.Exists(_configService.Current.LastSavedCharaDataLocation) ? _configService.Current.LastSavedCharaDataLocation : null); + } + UiSharedService.ColorTextWrapped("Note: For best results make sure you have everything you want to be shared as well as the correct character appearance" + + " equipped and redraw your character before exporting.", ImGuiColors.DalamudYellow); + + ImGui.Unindent(); + } + } + + private void DrawMetaInfoData(string selectedGposeActor, bool hasValidGposeTarget, CharaDataMetaInfoExtendedDto data, bool canOpen = false) + { + ImGuiHelpers.ScaledDummy(5); + using var entryId = ImRaii.PushId(data.FullId); + + var startPos = ImGui.GetCursorPosX(); + var maxPos = ImGui.GetWindowContentRegionMax().X; + var availableWidth = maxPos - startPos; + UiSharedService.DrawGrouped(() => + { + ImGui.AlignTextToFramePadding(); + DrawAddOrRemoveFavorite(data); + + ImGui.SameLine(); + var favPos = ImGui.GetCursorPosX(); + ImGui.AlignTextToFramePadding(); + UiSharedService.ColorText(data.FullId, UiSharedService.GetBoolColor(data.CanBeDownloaded)); + if (!data.CanBeDownloaded) + { + UiSharedService.AttachToolTip("This data is incomplete on the server and cannot be downloaded. Contact the owner so they can fix it. If you are the owner, review the data in the MCD Online tab."); + } + + var offsetFromRight = availableWidth - _uiSharedService.GetIconData(FontAwesomeIcon.Calendar).X - _uiSharedService.GetIconButtonSize(FontAwesomeIcon.ArrowRight).X + - _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Plus).X - ImGui.GetStyle().ItemSpacing.X * 2; + + ImGui.SameLine(); + ImGui.SetCursorPosX(offsetFromRight); + _uiSharedService.IconText(FontAwesomeIcon.Calendar); + UiSharedService.AttachToolTip($"Last Update: {data.UpdatedDate}"); + + ImGui.SameLine(); + GposeMetaInfoAction((meta) => + { + if (_uiSharedService.IconButton(FontAwesomeIcon.ArrowRight)) + { + _ = _charaDataManager.ApplyCharaDataToGposeTarget(meta!); + } + }, $"Apply Character data to {CharaName(selectedGposeActor)}", data, hasValidGposeTarget, false); + ImGui.SameLine(); + GposeMetaInfoAction((meta) => + { + if (_uiSharedService.IconButton(FontAwesomeIcon.Plus)) + { + _ = _charaDataManager.SpawnAndApplyData(meta!); + } + }, "Spawn and Apply Character data", data, hasValidGposeTarget, true); + + using var indent = ImRaii.PushIndent(favPos - startPos); + + if (canOpen) + { + using (ImRaii.Disabled(_isHandlingSelf)) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Edit, "Open in MCD Online Editor")) + { + _selectedDtoId = data.Id; + _openMcdOnlineOnNextRun = true; + } + } + if (_isHandlingSelf) + { + UiSharedService.AttachToolTip("Cannot use MCD Online while having Character Data applied to self."); + } + } + + if (string.IsNullOrEmpty(data.Description)) + { + UiSharedService.ColorTextWrapped("No description set", ImGuiColors.DalamudGrey, availableWidth); + } + else + { + UiSharedService.TextWrapped(data.Description, availableWidth); + } + + DrawPoseData(data, selectedGposeActor, hasValidGposeTarget); + }); + } + + + private void DrawPoseData(CharaDataMetaInfoExtendedDto? metaInfo, string actor, bool hasValidGposeTarget) + { + if (metaInfo == null || !metaInfo.HasPoses) return; + + bool isInGpose = _uiSharedService.IsInGpose; + var start = ImGui.GetCursorPosX(); + foreach (var item in metaInfo.PoseExtended) + { + if (!item.HasPoseData) continue; + + float DrawIcon(float s) + { + ImGui.SetCursorPosX(s); + var posX = ImGui.GetCursorPosX(); + _uiSharedService.IconText(item.HasWorldData ? FontAwesomeIcon.Circle : FontAwesomeIcon.Running); + if (item.HasWorldData) + { + ImGui.SameLine(); + ImGui.SetCursorPosX(posX); + using var col = ImRaii.PushColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.WindowBg)); + _uiSharedService.IconText(FontAwesomeIcon.Running); + ImGui.SameLine(); + ImGui.SetCursorPosX(posX); + _uiSharedService.IconText(FontAwesomeIcon.Running); + } + ImGui.SameLine(); + return ImGui.GetCursorPosX(); + } + + string tooltip = string.IsNullOrEmpty(item.Description) ? "No description set" : "Pose Description: " + item.Description; + if (!isInGpose) + { + start = DrawIcon(start); + UiSharedService.AttachToolTip(tooltip + UiSharedService.TooltipSeparator + (item.HasWorldData ? GetWorldDataTooltipText(item) + UiSharedService.TooltipSeparator + "Click to show on Map" : string.Empty)); + if (item.HasWorldData && ImGui.IsItemClicked(ImGuiMouseButton.Left)) + { + _dalamudUtilService.SetMarkerAndOpenMap(item.Position, item.Map); + } + } + else + { + tooltip += UiSharedService.TooltipSeparator + $"Left Click: Apply this pose to {CharaName(actor)}"; + if (item.HasWorldData) tooltip += Environment.NewLine + $"CTRL+Right Click: Apply world position to {CharaName(actor)}." + + UiSharedService.TooltipSeparator + "!!! CAUTION: Applying world position will likely yeet this actor into nirvana. Use at your own risk !!!"; + GposePoseAction(() => + { + start = DrawIcon(start); + if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) + { + _ = _charaDataManager.ApplyPoseData(item, actor); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Right) && UiSharedService.CtrlPressed()) + { + _ = _charaDataManager.ApplyWorldDataToTarget(item, actor); + } + }, tooltip, hasValidGposeTarget); + ImGui.SameLine(); + } + } + if (metaInfo.PoseExtended.Any()) ImGui.NewLine(); + } + + private void DrawSettings() + { + ImGuiHelpers.ScaledDummy(5); + _uiSharedService.BigText("Settings"); + ImGuiHelpers.ScaledDummy(5); + bool openInGpose = _configService.Current.OpenMareHubOnGposeStart; + if (ImGui.Checkbox("Open Character Data Hub when GPose loads", ref openInGpose)) + { + _configService.Current.OpenMareHubOnGposeStart = openInGpose; + _configService.Save(); + } + _uiSharedService.DrawHelpText("This will automatically open the import menu when loading into Gpose. If unchecked you can open the menu manually with /mare gpose"); + bool downloadDataOnConnection = _configService.Current.DownloadMcdDataOnConnection; + if (ImGui.Checkbox("Download MCD Online Data on connecting", ref downloadDataOnConnection)) + { + _configService.Current.DownloadMcdDataOnConnection = downloadDataOnConnection; + _configService.Save(); + } + _uiSharedService.DrawHelpText("This will automatically download MCD Online data (Your Own and Shared with You) once a connection is established to the server."); + + bool showHelpTexts = _configService.Current.ShowHelpTexts; + if (ImGui.Checkbox("Show \"What is this? (Explanation / Help)\" foldouts", ref showHelpTexts)) + { + _configService.Current.ShowHelpTexts = showHelpTexts; + _configService.Save(); + } + + ImGui.Checkbox("Abbreviate Chara Names", ref _abbreviateCharaName); + _uiSharedService.DrawHelpText("This setting will abbreviate displayed names. This setting is not persistent and will reset between restarts."); + + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Last Export Folder"); + ImGui.SameLine(300); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(string.IsNullOrEmpty(_configService.Current.LastSavedCharaDataLocation) ? "Not set" : _configService.Current.LastSavedCharaDataLocation); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Ban, "Clear Last Export Folder")) + { + _configService.Current.LastSavedCharaDataLocation = string.Empty; + _configService.Save(); + } + _uiSharedService.DrawHelpText("Use this if the Load or Save MCDF file dialog does not open"); + } + + private void DrawHelpFoldout(string text) + { + if (_configService.Current.ShowHelpTexts) + { + ImGuiHelpers.ScaledDummy(5); + UiSharedService.DrawTree("What is this? (Explanation / Help)", () => + { + UiSharedService.TextWrapped(text); + }); + } + } + + private void DisableDisabled(Action drawAction) + { + if (_disableUI) ImGui.EndDisabled(); + drawAction(); + if (_disableUI) ImGui.BeginDisabled(); + } +} \ No newline at end of file diff --git a/MareSynchronos/UI/GposeUi.cs b/MareSynchronos/UI/GposeUi.cs deleted file mode 100644 index 128007a..0000000 --- a/MareSynchronos/UI/GposeUi.cs +++ /dev/null @@ -1,100 +0,0 @@ -using Dalamud.Interface; -using Dalamud.Interface.Colors; -using Dalamud.Interface.ImGuiFileDialog; -using MareSynchronos.MareConfiguration; -using MareSynchronos.PlayerData.Export; -using MareSynchronos.Services; -using MareSynchronos.Services.Mediator; -using Microsoft.Extensions.Logging; - -namespace MareSynchronos.UI; - -public class GposeUi : WindowMediatorSubscriberBase -{ - private readonly MareConfigService _configService; - private readonly UiSharedService _uiSharedService; - private readonly DalamudUtilService _dalamudUtil; - private readonly FileDialogManager _fileDialogManager; - private readonly MareCharaFileManager _mareCharaFileManager; - private Task? _expectedLength; - private Task? _applicationTask; - - public GposeUi(ILogger logger, MareCharaFileManager mareCharaFileManager, - DalamudUtilService dalamudUtil, FileDialogManager fileDialogManager, MareConfigService configService, - MareMediator mediator, PerformanceCollectorService performanceCollectorService, UiSharedService uiSharedService) - : base(logger, mediator, "Loporrit Gpose Import UI###LoporritSyncGposeUI", performanceCollectorService) - { - _mareCharaFileManager = mareCharaFileManager; - _dalamudUtil = dalamudUtil; - _fileDialogManager = fileDialogManager; - _configService = configService; - _uiSharedService = uiSharedService; - Mediator.Subscribe(this, (_) => StartGpose()); - Mediator.Subscribe(this, (_) => EndGpose()); - IsOpen = _dalamudUtil.IsInGpose; - this.SizeConstraints = new() - { - MinimumSize = new(200, 200), - MaximumSize = new(400, 400) - }; - } - - protected override void DrawInternal() - { - if (!_dalamudUtil.IsInGpose) IsOpen = false; - - if (!_mareCharaFileManager.CurrentlyWorking) - { - if (_uiSharedService.IconTextButton(FontAwesomeIcon.FolderOpen, "Load MCDF")) - { - _fileDialogManager.OpenFileDialog("Pick MCDF file", ".mcdf", (success, paths) => - { - if (!success) return; - if (paths.FirstOrDefault() is not string path) return; - - _configService.Current.ExportFolder = Path.GetDirectoryName(path) ?? string.Empty; - _configService.Save(); - - _expectedLength = Task.Run(() => _mareCharaFileManager.LoadMareCharaFile(path)); - }, 1, Directory.Exists(_configService.Current.ExportFolder) ? _configService.Current.ExportFolder : null); - } - UiSharedService.AttachToolTip("Applies it to the currently selected GPose actor"); - if (_mareCharaFileManager.LoadedCharaFile != null && _expectedLength != null) - { - UiSharedService.TextWrapped("Loaded file: " + _mareCharaFileManager.LoadedCharaFile.FilePath); - UiSharedService.TextWrapped("File Description: " + _mareCharaFileManager.LoadedCharaFile.CharaFileData.Description); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Check, "Apply loaded MCDF")) - { - _applicationTask = _dalamudUtil.RunOnFrameworkThread(async () => await _mareCharaFileManager.ApplyMareCharaFile(_dalamudUtil.GposeTargetGameObject, _expectedLength!.GetAwaiter().GetResult()).ConfigureAwait(false)); - } - UiSharedService.AttachToolTip("Applies it to the currently selected GPose actor"); - UiSharedService.ColorTextWrapped("Warning: redrawing or changing the character will revert all applied mods.", ImGuiColors.DalamudYellow); - } - if (_applicationTask?.IsFaulted ?? false) - { - UiSharedService.ColorTextWrapped("Failure to read MCDF file. MCDF file is possibly corrupt. Re-export the MCDF file and try again.", - ImGuiColors.DalamudRed); - UiSharedService.ColorTextWrapped("Note: if this is your MCDF, try redrawing yourself, wait and re-export the file. " + - "If you received it from someone else have them do the same.", ImGuiColors.DalamudYellow); - } - } - else - { - UiSharedService.ColorTextWrapped("Loading Character...", ImGuiColors.DalamudYellow); - } - UiSharedService.TextWrapped("Hint: You can disable the automatic loading of this window in the Loporrit settings and open it manually with /sync gpose"); - } - - private void EndGpose() - { - IsOpen = false; - _applicationTask = null; - _expectedLength = null; - _mareCharaFileManager.ClearMareCharaFile(); - } - - private void StartGpose() - { - IsOpen = _configService.Current.OpenGposeImportOnGposeStart; - } -} \ No newline at end of file diff --git a/MareSynchronos/UI/SettingsUi.cs b/MareSynchronos/UI/SettingsUi.cs index c5c1489..180d91d 100644 --- a/MareSynchronos/UI/SettingsUi.cs +++ b/MareSynchronos/UI/SettingsUi.cs @@ -13,7 +13,6 @@ using MareSynchronos.FileCache; using MareSynchronos.Interop.Ipc; using MareSynchronos.MareConfiguration; using MareSynchronos.MareConfiguration.Models; -using MareSynchronos.PlayerData.Export; using MareSynchronos.PlayerData.Handlers; using MareSynchronos.PlayerData.Pairs; using MareSynchronos.Services; @@ -47,7 +46,6 @@ public class SettingsUi : WindowMediatorSubscriberBase private readonly FileUploadManager _fileTransferManager; private readonly FileTransferOrchestrator _fileTransferOrchestrator; private readonly FileCacheManager _fileCacheManager; - private readonly MareCharaFileManager _mareCharaFileManager; private readonly PairManager _pairManager; private readonly ChatService _chatService; private readonly GuiHookService _guiHookService; @@ -58,16 +56,14 @@ public class SettingsUi : WindowMediatorSubscriberBase private readonly UiSharedService _uiShared; private bool _deleteAccountPopupModalShown = false; private bool _deleteFilesPopupModalShown = false; - private string _exportDescription = string.Empty; private string _lastTab = string.Empty; private bool? _notesSuccessfullyApplied = null; private bool _overwriteExistingLabels = false; private bool _readClearCache = false; - private bool _readExport = false; + private CancellationTokenSource? _validationCts; + private Task>? _validationTask; private bool _wasOpen = false; private readonly IProgress<(int, int, FileCacheEntity)> _validationProgress; - private Task>? _validationTask; - private CancellationTokenSource? _validationCts; private (int, int, FileCacheEntity) _currentProgress; private Task? _exportTask; @@ -77,7 +73,7 @@ public class SettingsUi : WindowMediatorSubscriberBase public SettingsUi(ILogger logger, UiSharedService uiShared, MareConfigService configService, - MareCharaFileManager mareCharaFileManager, PairManager pairManager, ChatService chatService, GuiHookService guiHookService, + PairManager pairManager, ChatService chatService, GuiHookService guiHookService, ServerConfigurationManager serverConfigurationManager, PlayerPerformanceConfigService playerPerformanceConfigService, PlayerPerformanceService playerPerformanceService, MareMediator mediator, PerformanceCollectorService performanceCollector, @@ -89,7 +85,6 @@ public class SettingsUi : WindowMediatorSubscriberBase DalamudUtilService dalamudUtilService) : base(logger, mediator, "Loporrit Settings", performanceCollector) { _configService = configService; - _mareCharaFileManager = mareCharaFileManager; _pairManager = pairManager; _chatService = chatService; _guiHookService = guiHookService; @@ -742,62 +737,18 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.BigText("Export MCDF"); - UiSharedService.TextWrapped("This feature allows you to pack your character appearance into a MCDF file and manually send it to other people. MCDF files can imported during GPose."); + ImGuiHelpers.ScaledDummy(10); - ImGui.Checkbox("##readExport", ref _readExport); - ImGui.SameLine(); - UiSharedService.TextWrapped("I understand that by exporting my character data and sending it to other people I am giving away my current character appearance irrevocably. People I am sharing my data with have the ability to share it with other people without limitations."); - - if (_readExport) + UiSharedService.ColorTextWrapped("Exporting MCDF has moved.", ImGuiColors.DalamudYellow); + ImGuiHelpers.ScaledDummy(5); + UiSharedService.TextWrapped("It is now found in the Main UI under \"Character Data Hub\""); + if (_uiShared.IconTextButton(FontAwesomeIcon.Running, "Open Character Data Hub")) { - ImGui.Indent(); - - if (!_mareCharaFileManager.CurrentlyWorking) - { - ImGui.InputTextWithHint("Export Descriptor", "This description will be shown on loading the data", ref _exportDescription, 255); - if (_uiShared.IconTextButton(FontAwesomeIcon.Save, "Export Character as MCDF")) - { - string defaultFileName = string.IsNullOrEmpty(_exportDescription) - ? "export.mcdf" - : string.Join('_', $"{_exportDescription}.mcdf".Split(Path.GetInvalidFileNameChars())); - _uiShared.FileDialogManager.SaveFileDialog("Export Character to file", ".mcdf", defaultFileName, ".mcdf", (success, path) => - { - if (!success) return; - - _configService.Current.ExportFolder = Path.GetDirectoryName(path) ?? string.Empty; - _configService.Save(); - - _exportTask = Task.Run(() => - { - var desc = _exportDescription; - _exportDescription = string.Empty; - _mareCharaFileManager.SaveMareCharaFile(LastCreatedCharacterData, desc, path); - }); - }, Directory.Exists(_configService.Current.ExportFolder) ? _configService.Current.ExportFolder : null); - } - UiSharedService.ColorTextWrapped("Note: For best results make sure you have everything you want to be shared as well as the correct character appearance" + - " equipped and redraw your character before exporting.", ImGuiColors.DalamudYellow); - } - else - { - UiSharedService.ColorTextWrapped("Export in progress", ImGuiColors.DalamudYellow); - } - - if (_exportTask?.IsFaulted ?? false) - { - UiSharedService.ColorTextWrapped("Export failed, check /xllog for more details.", ImGuiColors.DalamudRed); - } - - ImGui.Unindent(); + Mediator.Publish(new UiToggleMessage(typeof(CharaDataHubUi))); } - bool openInGpose = _configService.Current.OpenGposeImportOnGposeStart; - if (ImGui.Checkbox("Open MCDF import window when GPose loads", ref openInGpose)) - { - _configService.Current.OpenGposeImportOnGposeStart = openInGpose; - _configService.Save(); - } - _uiShared.DrawHelpText("This will automatically open the import menu when loading into Gpose. If unchecked you can open the menu manually with /sync gpose"); + UiSharedService.TextWrapped("Note: this entry will be removed in the near future. Please use the Main UI to open the Character Data Hub."); + ImGuiHelpers.ScaledDummy(5); ImGui.Separator(); _uiShared.BigText("Storage"); @@ -1958,7 +1909,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.EndTabItem(); } - if (ImGui.BeginTabItem("Export & Storage")) + if (ImGui.BeginTabItem("Storage")) { DrawFileStorageSettings(); ImGui.EndTabItem(); diff --git a/MareSynchronos/UI/UISharedService.cs b/MareSynchronos/UI/UISharedService.cs index ff966d5..86e8d57 100644 --- a/MareSynchronos/UI/UISharedService.cs +++ b/MareSynchronos/UI/UISharedService.cs @@ -78,6 +78,7 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase private bool _moodlesExists = false; private bool _penumbraExists = false; private bool _petNamesExists = false; + private bool _brioExists = false; private int _serverSelectionIndex = -1; @@ -111,6 +112,7 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase _honorificExists = _ipcManager.Honorific.APIAvailable; _petNamesExists = _ipcManager.PetNames.APIAvailable; _moodlesExists = _ipcManager.Moodles.APIAvailable; + _brioExists = _ipcManager.Brio.APIAvailable; }); UidFont = _pluginInterface.UiBuilder.FontAtlas.NewDelegateFontHandle(e => @@ -133,7 +135,7 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase public bool HasValidPenumbraModPath => !(_ipcManager.Penumbra.ModDirectory ?? string.Empty).IsNullOrEmpty() && Directory.Exists(_ipcManager.Penumbra.ModDirectory); public IFontHandle IconFont { get; init; } - public bool IsInGpose => _dalamudUtil.IsInCutscene; + public bool IsInGpose => _dalamudUtil.IsInGpose; public string PlayerName => _dalamudUtil.GetPlayerName(); @@ -207,14 +209,46 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase ImGui.TextUnformatted(text); } - public static void ColorTextWrapped(string text, Vector4 color) + public static void ColorTextWrapped(string text, Vector4 color, float wrapPos = 0) { using var raiicolor = ImRaii.PushColor(ImGuiCol.Text, color); - TextWrapped(text); + TextWrapped(text, wrapPos); } public static bool CtrlPressed() => (GetKeyState(0xA2) & 0x8000) != 0 || (GetKeyState(0xA3) & 0x8000) != 0; + public static void DrawGrouped(Action imguiDrawAction, float rounding = 5f, float? expectedWidth = null) + { + var cursorPos = ImGui.GetCursorPos(); + using (ImRaii.Group()) + { + if (expectedWidth != null) + { + ImGui.Dummy(new(expectedWidth.Value, 0)); + ImGui.SetCursorPos(cursorPos); + } + + imguiDrawAction.Invoke(); + } + + ImGui.GetWindowDrawList().AddRect( + ImGui.GetItemRectMin() - ImGui.GetStyle().ItemInnerSpacing, + ImGui.GetItemRectMax() + ImGui.GetStyle().ItemInnerSpacing, + Color(ImGuiColors.DalamudGrey2), rounding); + } + + public static void DrawGroupedCenteredColorText(string text, Vector4 color, float? maxWidth = null) + { + var availWidth = ImGui.GetContentRegionAvail().X; + var textWidth = ImGui.CalcTextSize(text, availWidth).X; + if (maxWidth != null && textWidth > maxWidth * ImGuiHelpers.GlobalScale) textWidth = maxWidth.Value * ImGuiHelpers.GlobalScale; + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + (availWidth / 2f) - (textWidth / 2f)); + DrawGrouped(() => + { + ColorTextWrapped(text, color, ImGui.GetCursorPosX() + textWidth); + }, expectedWidth: maxWidth == null ? null : maxWidth * ImGuiHelpers.GlobalScale); + } + public static void DrawOutlinedFont(string text, Vector4 fontColor, Vector4 outlineColor, int thickness) { var original = ImGui.GetCursorPos(); @@ -271,6 +305,15 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase drawList.AddText(textPos, fontColor, text); } + public static void DrawTree(string leafName, Action drawOnOpened, ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags.None) + { + using var tree = ImRaii.TreeNode(leafName, flags); + if (tree) + { + drawOnOpened(); + } + } + public static Vector4 GetBoolColor(bool input) => input ? ImGuiColors.ParsedGreen : ImGuiColors.DalamudRed; public float GetIconTextButtonSize(FontAwesomeIcon icon, string text) @@ -428,9 +471,9 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase public static bool ShiftPressed() => (GetKeyState(0xA1) & 0x8000) != 0 || (GetKeyState(0xA0) & 0x8000) != 0; - public static void TextWrapped(string text) + public static void TextWrapped(string text, float wrapPos = 0) { - ImGui.PushTextWrapPos(0); + ImGui.PushTextWrapPos(wrapPos); ImGui.TextUnformatted(text); ImGui.PopTextWrapPos(); } @@ -739,15 +782,10 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase IconText(_penumbraExists ? check : cross, GetBoolColor(_penumbraExists)); ImGui.SameLine(); AttachToolTip($"Penumbra is " + (_penumbraExists ? "available and up to date." : "unavailable or not up to date.")); - ImGui.Spacing(); ImGui.SameLine(); - ImGui.TextUnformatted("Glamourer"); - ImGui.SameLine(); - IconText(_glamourerExists ? check : cross, GetBoolColor(_glamourerExists)); - ImGui.SameLine(); + ColorText("Glamourer", GetBoolColor(_glamourerExists)); AttachToolTip($"Glamourer is " + (_glamourerExists ? "available and up to date." : "unavailable or not up to date.")); - ImGui.Spacing(); if (intro) { @@ -803,6 +841,14 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase AttachToolTip($"Moodles is " + (_moodlesExists ? "available and up to date." : "unavailable or not up to date.")); ImGui.Spacing(); + ImGui.SameLine(); + ImGui.TextUnformatted("Brio"); + ImGui.SameLine(); + IconText(_brioExists ? check : cross, GetBoolColor(_brioExists)); + ImGui.SameLine(); + AttachToolTip($"Brio is " + (_moodlesExists ? "available and up to date." : "unavailable or not up to date.")); + ImGui.Spacing(); + if (!_penumbraExists || !_glamourerExists) { ImGui.TextColored(ImGuiColors.DalamudRed, "You need to install both Penumbra and Glamourer and keep them up to date to use Loporrit."); @@ -923,6 +969,13 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase Strings.ToS = new Strings.ToSStrings(); } + internal static void DistanceSeparator() + { + ImGuiHelpers.ScaledDummy(5); + ImGui.Separator(); + ImGuiHelpers.ScaledDummy(5); + } + [LibraryImport("user32")] internal static partial short GetKeyState(int nVirtKey); diff --git a/MareSynchronos/Utils/ValueProgress.cs b/MareSynchronos/Utils/ValueProgress.cs new file mode 100644 index 0000000..92dfeae --- /dev/null +++ b/MareSynchronos/Utils/ValueProgress.cs @@ -0,0 +1,22 @@ +namespace MareSynchronos.Utils; + +public class ValueProgress : Progress +{ + public T? Value { get; set; } + + protected override void OnReport(T value) + { + base.OnReport(value); + Value = value; + } + + public void Report(T value) + { + OnReport(value); + } + + public void Clear() + { + Value = default; + } +} diff --git a/MareSynchronos/WebAPI/Files/FileUploadManager.cs b/MareSynchronos/WebAPI/Files/FileUploadManager.cs index 5611f17..1fb7dba 100644 --- a/MareSynchronos/WebAPI/Files/FileUploadManager.cs +++ b/MareSynchronos/WebAPI/Files/FileUploadManager.cs @@ -65,6 +65,43 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase await _orchestrator.SendRequestAsync(HttpMethod.Post, MareFiles.ServerFilesDeleteAllFullPath(_orchestrator.FilesCdnUri!)).ConfigureAwait(false); } + public async Task> UploadFiles(List hashesToUpload, IProgress progress, CancellationToken? ct = null) + { + Logger.LogDebug("Trying to upload files"); + var filesPresentLocally = hashesToUpload.Where(h => _fileDbManager.GetFileCacheByHash(h) != null).ToHashSet(StringComparer.Ordinal); + var locallyMissingFiles = hashesToUpload.Except(filesPresentLocally, StringComparer.Ordinal).ToList(); + if (locallyMissingFiles.Any()) + { + return locallyMissingFiles; + } + + progress.Report($"Starting upload for {filesPresentLocally.Count} files"); + + var filesToUpload = await FilesSend([.. filesPresentLocally], [], ct ?? CancellationToken.None).ConfigureAwait(false); + + if (filesToUpload.Exists(f => f.IsForbidden)) + { + return [.. filesToUpload.Where(f => f.IsForbidden).Select(f => f.Hash)]; + } + + Task uploadTask = Task.CompletedTask; + int i = 1; + foreach (var file in filesToUpload) + { + progress.Report($"Uploading file {i++}/{filesToUpload.Count}. Please wait until the upload is completed."); + Logger.LogDebug("[{hash}] Compressing", file); + var data = await _fileDbManager.GetCompressedFileData(file.Hash, ct ?? CancellationToken.None).ConfigureAwait(false); + Logger.LogDebug("[{hash}] Starting upload for {filePath}", data.Item1, _fileDbManager.GetFileCacheByHash(data.Item1)!.ResolvedFilepath); + await uploadTask.ConfigureAwait(false); + uploadTask = UploadFile(data.Item2, file.Hash, false, ct ?? CancellationToken.None); + (ct ?? CancellationToken.None).ThrowIfCancellationRequested(); + } + + await uploadTask.ConfigureAwait(false); + + return []; + } + public async Task UploadFiles(CharacterData data, List visiblePlayers) { CancelUpload(); @@ -135,7 +172,7 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase _verifiedUploadedHashes.Clear(); } - private async Task UploadFile(byte[] compressedFile, string fileHash, CancellationToken uploadToken) + private async Task UploadFile(byte[] compressedFile, string fileHash, bool postProgress, CancellationToken uploadToken) { if (!_orchestrator.IsInitialized) throw new InvalidOperationException("FileTransferManager is not initialized"); @@ -145,7 +182,7 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase try { - await UploadFileStream(compressedFile, fileHash, false, uploadToken).ConfigureAwait(false); + await UploadFileStream(compressedFile, fileHash, false, postProgress, uploadToken).ConfigureAwait(false); _verifiedUploadedHashes[fileHash] = DateTime.UtcNow; } catch (Exception ex) @@ -153,7 +190,7 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase if (false && ex is not OperationCanceledException) { Logger.LogWarning(ex, "[{hash}] Error during file upload, trying alternative file upload", fileHash); - await UploadFileStream(compressedFile, fileHash, munged: true, uploadToken).ConfigureAwait(false); + await UploadFileStream(compressedFile, fileHash, munged: true, postProgress, uploadToken).ConfigureAwait(false); } else { @@ -162,14 +199,14 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase } } - private async Task UploadFileStream(byte[] compressedFile, string fileHash, bool munged, CancellationToken uploadToken) + private async Task UploadFileStream(byte[] compressedFile, string fileHash, bool munged, bool postProgress, CancellationToken uploadToken) { if (munged) throw new NotImplementedException(); using var ms = new MemoryStream(compressedFile); - Progress prog = new((prog) => + Progress? prog = !postProgress ? null : new((prog) => { try { @@ -180,6 +217,7 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase Logger.LogWarning(ex, "[{hash}] Could not set upload progress", fileHash); } }); + var streamContent = new ProgressableStreamContent(ms, prog); streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); HttpResponseMessage response; @@ -235,7 +273,7 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase CurrentUploads.Single(e => string.Equals(e.Hash, file.Hash, StringComparison.Ordinal)).Total = data.Item2.Length; Logger.LogDebug("[{hash}] Starting upload for {filePath}", file.Hash, _fileDbManager.GetFileCacheByHash(file.Hash)!.ResolvedFilepath); await uploadTask.ConfigureAwait(false); - uploadTask = UploadFile(data.Item2, file.Hash, uploadToken); + uploadTask = UploadFile(data.Item2, file.Hash, true, uploadToken); uploadToken.ThrowIfCancellationRequested(); } diff --git a/MareSynchronos/WebAPI/Files/Models/ProgressableStreamContent.cs b/MareSynchronos/WebAPI/Files/Models/ProgressableStreamContent.cs index 10ffbd7..7283a2b 100644 --- a/MareSynchronos/WebAPI/Files/Models/ProgressableStreamContent.cs +++ b/MareSynchronos/WebAPI/Files/Models/ProgressableStreamContent.cs @@ -6,16 +6,16 @@ public class ProgressableStreamContent : StreamContent { private const int _defaultBufferSize = 4096; private readonly int _bufferSize; - private readonly IProgress _progress; + private readonly IProgress? _progress; private readonly Stream _streamToWrite; private bool _contentConsumed; - public ProgressableStreamContent(Stream streamToWrite, IProgress downloader) + public ProgressableStreamContent(Stream streamToWrite, IProgress? downloader) : this(streamToWrite, _defaultBufferSize, downloader) { } - public ProgressableStreamContent(Stream streamToWrite, int bufferSize, IProgress progress) + public ProgressableStreamContent(Stream streamToWrite, int bufferSize, IProgress? progress) : base(streamToWrite, bufferSize) { if (streamToWrite == null) @@ -55,14 +55,14 @@ public class ProgressableStreamContent : StreamContent { while (true) { - var length = _streamToWrite.Read(buffer, 0, buffer.Length); + var length = await _streamToWrite.ReadAsync(buffer).ConfigureAwait(false); if (length <= 0) { break; } uploaded += length; - _progress.Report(new UploadProgress(uploaded, size)); + _progress?.Report(new UploadProgress(uploaded, size)); await stream.WriteAsync(buffer.AsMemory(0, length)).ConfigureAwait(false); } } diff --git a/MareSynchronos/WebAPI/SignalR/ApiController.Functions.CharaData.cs b/MareSynchronos/WebAPI/SignalR/ApiController.Functions.CharaData.cs new file mode 100644 index 0000000..865c4aa --- /dev/null +++ b/MareSynchronos/WebAPI/SignalR/ApiController.Functions.CharaData.cs @@ -0,0 +1,119 @@ +using MareSynchronos.API.Dto.CharaData; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.Logging; + +namespace MareSynchronos.WebAPI; +public partial class ApiController +{ + public async Task CharaDataCreate() + { + if (!IsConnected) return null; + + try + { + Logger.LogDebug("Creating new Character Data"); + return await _mareHub!.InvokeAsync(nameof(CharaDataCreate)).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to create new character data"); + return null; + } + } + + public async Task CharaDataUpdate(CharaDataUpdateDto updateDto) + { + if (!IsConnected) return null; + + try + { + Logger.LogDebug("Updating chara data for {id}", updateDto.Id); + return await _mareHub!.InvokeAsync(nameof(CharaDataUpdate), updateDto).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to update chara data for {id}", updateDto.Id); + return null; + } + } + + public async Task CharaDataDelete(string id) + { + if (!IsConnected) return false; + + try + { + Logger.LogDebug("Deleting chara data for {id}", id); + return await _mareHub!.InvokeAsync(nameof(CharaDataDelete), id).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to delete chara data for {id}", id); + return false; + } + } + + public async Task CharaDataGetMetainfo(string id) + { + if (!IsConnected) return null; + + try + { + Logger.LogDebug("Getting metainfo for chara data {id}", id); + return await _mareHub!.InvokeAsync(nameof(CharaDataGetMetainfo), id).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to get meta info for chara data {id}", id); + return null; + } + } + + public async Task> CharaDataGetOwn() + { + if (!IsConnected) return []; + + try + { + Logger.LogDebug("Getting all own chara data"); + return await _mareHub!.InvokeAsync>(nameof(CharaDataGetOwn)).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to get own chara data"); + return []; + } + } + + public async Task> CharaDataGetShared() + { + if (!IsConnected) return []; + + try + { + Logger.LogDebug("Getting all own chara data"); + return await _mareHub!.InvokeAsync>(nameof(CharaDataGetShared)).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to get shared chara data"); + return []; + } + } + + public async Task CharaDataDownload(string id) + { + if (!IsConnected) return null; + + try + { + Logger.LogDebug("Getting download chara data for {id}", id); + return await _mareHub!.InvokeAsync(nameof(CharaDataDownload), id).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to get download chara data for {id}", id); + return null; + } + } +}