From 7a8655f6b1c0904b30deb495ce7779e006361a23 Mon Sep 17 00:00:00 2001 From: rootdarkarchon Date: Mon, 23 Jan 2023 12:56:43 +0100 Subject: [PATCH] Chara file data export (#38) * add rudimentary saving of chara file data * fix building * working prototype for MCDF import and application * adjust code to use streams * rename cache -> storage add ui for import/export mcdf * minor wording adjustments, version bump Co-authored-by: rootdarkarchon Co-authored-by: Stanley Dimant --- MareSynchronos/Configuration.cs | 1 + MareSynchronos/Export/MareCharaFileData.cs | 65 +++++++ .../Export/MareCharaFileDataFactory.cs | 19 ++ MareSynchronos/Export/MareCharaFileHeader.cs | 57 ++++++ MareSynchronos/Export/MareCharaFileManager.cs | 163 ++++++++++++++++++ MareSynchronos/Managers/IpcManager.cs | 23 ++- MareSynchronos/MareSynchronos.csproj | 2 +- MareSynchronos/Plugin.cs | 53 +++--- MareSynchronos/UI/CompactUI.cs | 17 ++ MareSynchronos/UI/DownloadUi.cs | 8 + MareSynchronos/UI/GposeUi.cs | 87 ++++++++++ MareSynchronos/UI/IntroUI.cs | 9 +- MareSynchronos/UI/SettingsUi.cs | 113 +++++++++++- MareSynchronos/UI/UIShared.cs | 38 ++-- MareSynchronos/Utils/DalamudUtil.cs | 60 ++++++- 15 files changed, 664 insertions(+), 51 deletions(-) create mode 100644 MareSynchronos/Export/MareCharaFileData.cs create mode 100644 MareSynchronos/Export/MareCharaFileDataFactory.cs create mode 100644 MareSynchronos/Export/MareCharaFileHeader.cs create mode 100644 MareSynchronos/Export/MareCharaFileManager.cs create mode 100644 MareSynchronos/UI/GposeUi.cs diff --git a/MareSynchronos/Configuration.cs b/MareSynchronos/Configuration.cs index fe8aae5..294e923 100644 --- a/MareSynchronos/Configuration.cs +++ b/MareSynchronos/Configuration.cs @@ -83,6 +83,7 @@ public class Configuration : IPluginConfiguration public bool FullPause { get; set; } = false; public bool HideInfoMessages { get; set; } = false; public bool DisableOptionalPluginWarnings { get; set; } = false; + public bool OpenGposeImportOnGposeStart { get; set; } = false; public Dictionary> UidServerComments { get; set; } = new(StringComparer.Ordinal); public Dictionary> GidServerComments { get; set; } = new(StringComparer.Ordinal); diff --git a/MareSynchronos/Export/MareCharaFileData.cs b/MareSynchronos/Export/MareCharaFileData.cs new file mode 100644 index 0000000..6b9f838 --- /dev/null +++ b/MareSynchronos/Export/MareCharaFileData.cs @@ -0,0 +1,65 @@ +using MareSynchronos.API; +using MareSynchronos.FileCache; +using Newtonsoft.Json; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace MareSynchronos.Export; + +public record MareCharaFileData +{ + public string Description { get; set; } = string.Empty; + public string GlamourerData { get; set; } = string.Empty; + public string CustomizePlusData { get; set; } = string.Empty; + public string ManipulationData { get; set; } = string.Empty; + public List Files { get; set; } = new(); + public List FileSwaps { get; set; } = new(); + + public MareCharaFileData() { } + public MareCharaFileData(FileCacheManager manager, string description, CharacterCacheDto dto) + { + Description = description; + + if (dto.GlamourerData.TryGetValue(ObjectKind.Player, out var glamourerData)) + { + GlamourerData = glamourerData; + } + + CustomizePlusData = dto.CustomizePlusData; + ManipulationData = dto.ManipulationData; + + if (dto.FileReplacements.TryGetValue(ObjectKind.Player, out var fileReplacements)) + { + foreach (var file in fileReplacements) + { + if (!string.IsNullOrEmpty(file.FileSwapPath)) + { + FileSwaps.Add(new FileSwap(file.GamePaths, file.FileSwapPath)); + } + else + { + var filePath = manager.GetFileCacheByHash(file.Hash)?.ResolvedFilepath; + if (filePath != null) + { + Files.Add(new FileData(file.GamePaths, new FileInfo(filePath).Length)); + } + } + } + } + } + + public byte[] ToByteArray() + { + return Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(this)); + } + + public static MareCharaFileData FromByteArray(byte[] data) + { + return JsonConvert.DeserializeObject(Encoding.UTF8.GetString(data))!; + } + + public record FileSwap(IEnumerable GamePaths, string FileSwapPath); + + public record FileData(IEnumerable GamePaths, long Length); +} diff --git a/MareSynchronos/Export/MareCharaFileDataFactory.cs b/MareSynchronos/Export/MareCharaFileDataFactory.cs new file mode 100644 index 0000000..9904679 --- /dev/null +++ b/MareSynchronos/Export/MareCharaFileDataFactory.cs @@ -0,0 +1,19 @@ +using MareSynchronos.API; +using MareSynchronos.FileCache; + +namespace MareSynchronos.Export; + +internal class MareCharaFileDataFactory +{ + private readonly FileCacheManager _fileCacheManager; + + public MareCharaFileDataFactory(FileCacheManager fileCacheManager) + { + _fileCacheManager = fileCacheManager; + } + + public MareCharaFileData Create(string description, CharacterCacheDto characterCacheDto) + { + return new MareCharaFileData(_fileCacheManager, description, characterCacheDto); + } +} diff --git a/MareSynchronos/Export/MareCharaFileHeader.cs b/MareSynchronos/Export/MareCharaFileHeader.cs new file mode 100644 index 0000000..0304086 --- /dev/null +++ b/MareSynchronos/Export/MareCharaFileHeader.cs @@ -0,0 +1,57 @@ +using Lumina.Extensions; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; + +namespace MareSynchronos.Export; + +public record MareCharaFileHeader(byte Version, MareCharaFileData CharaFileData) +{ + public static readonly byte CurrentVersion = 1; + + public byte Version { get; set; } = Version; + public MareCharaFileData CharaFileData { get; set; } = CharaFileData; + public string FilePath { get; private set; } + + public void WriteToStream(BinaryWriter writer) + { + writer.Write('M'); + writer.Write('C'); + writer.Write('D'); + writer.Write('F'); + writer.Write(Version); + var charaFileDataArray = CharaFileData.ToByteArray(); + writer.Write(charaFileDataArray.Length); + writer.Write(charaFileDataArray); + } + + public static MareCharaFileHeader? FromBinaryReader(string path, BinaryReader reader) + { + var chars = new string(reader.ReadChars(4)); + if (!string.Equals(chars, "MCDF", System.StringComparison.Ordinal)) throw new System.Exception("Not a Mare Chara File"); + + MareCharaFileHeader? decoded = null; + + var version = reader.ReadByte(); + if (version == 1) + { + var dataLength = reader.ReadInt32(); + + decoded = new(version, MareCharaFileData.FromByteArray(reader.ReadBytes(dataLength))); + decoded.FilePath = path; + } + + return decoded; + } + + public void AdvanceReaderToData(BinaryReader reader) + { + reader.ReadChars(4); + var version = reader.ReadByte(); + if (version == 1) + { + var length = reader.ReadInt32(); + _ = reader.ReadBytes(length); + } + } +} diff --git a/MareSynchronos/Export/MareCharaFileManager.cs b/MareSynchronos/Export/MareCharaFileManager.cs new file mode 100644 index 0000000..96bccfa --- /dev/null +++ b/MareSynchronos/Export/MareCharaFileManager.cs @@ -0,0 +1,163 @@ +using System.Linq; + +using Dalamud.Game.ClientState.Objects.Types; +using LZ4; +using MareSynchronos.API; +using MareSynchronos.FileCache; +using MareSynchronos.Managers; +using MareSynchronos.Utils; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace MareSynchronos.Export; +public class MareCharaFileManager +{ + private readonly FileCacheManager _manager; + private readonly IpcManager _ipcManager; + private readonly DalamudUtil _dalamudUtil; + private readonly MareCharaFileDataFactory _factory; + public MareCharaFileHeader? LoadedCharaFile { get; private set; } + public bool CurrentlyWorking { get; private set; } = false; + + public MareCharaFileManager(FileCacheManager manager, IpcManager ipcManager, DalamudUtil dalamudUtil) + { + _factory = new(manager); + _manager = manager; + _ipcManager = ipcManager; + _dalamudUtil = dalamudUtil; + } + + public void ClearMareCharaFile() + { + LoadedCharaFile = null; + } + + public void 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.Debug("Read Mare Chara File"); + Logger.Debug("Version: " + LoadedCharaFile.Version); + + } + catch { throw; } + finally { CurrentlyWorking = false; } + } + + public async Task ApplyMareCharaFile(GameObject charaTarget) + { + Dictionary extractedFiles = new(); + CurrentlyWorking = true; + try + { + if (LoadedCharaFile == null || charaTarget == null || !File.Exists(LoadedCharaFile.FilePath)) return; + + using var unwrapped = File.OpenRead(LoadedCharaFile.FilePath); + using var lz4Stream = new LZ4Stream(unwrapped, LZ4StreamMode.Decompress, LZ4StreamFlags.HighCompression); + using var reader = new BinaryReader(lz4Stream); + LoadedCharaFile.AdvanceReaderToData(reader); + Logger.Debug("Applying to " + charaTarget.Name.TextValue); + extractedFiles = ExtractFilesFromCharaFile(LoadedCharaFile, reader); + Dictionary fileSwaps = new(StringComparer.Ordinal); + foreach (var fileSwap in LoadedCharaFile.CharaFileData.FileSwaps) + { + foreach (var path in fileSwap.GamePaths) + { + fileSwaps.Add(path, fileSwap.FileSwapPath); + } + } + _ipcManager.ToggleGposeQueueMode(true); + _ipcManager.PenumbraRemoveTemporaryCollection(charaTarget.Name.TextValue); + _ipcManager.PenumbraSetTemporaryMods(charaTarget.Name.TextValue, + extractedFiles.Union(fileSwaps).ToDictionary(d => d.Key, d => d.Value, StringComparer.Ordinal), + LoadedCharaFile.CharaFileData.ManipulationData); + _ipcManager.GlamourerApplyAll(LoadedCharaFile.CharaFileData.GlamourerData, charaTarget.Address); + _dalamudUtil.WaitWhileGposeCharacterIsDrawing(charaTarget.Address); + _ipcManager.PenumbraRemoveTemporaryCollection(charaTarget.Name.TextValue); + _ipcManager.ToggleGposeQueueMode(false); + } + catch { throw; } + finally + { + CurrentlyWorking = false; + + Logger.Debug("Clearing local files"); + foreach (var file in extractedFiles) + { + File.Delete(file.Value); + } + } + } + + private Dictionary ExtractFilesFromCharaFile(MareCharaFileHeader charaFileHeader, BinaryReader reader) + { + Dictionary gamePathToFilePath = new(StringComparer.Ordinal); + int i = 0; + foreach (var fileData in charaFileHeader.CharaFileData.Files) + { + var fileName = Path.Combine(Path.GetTempPath(), "mare_" + (i++) + ".tmp"); + var length = fileData.Length; + var bufferSize = 4 * 1024 * 1024; + var buffer = new byte[bufferSize]; + using var fs = File.OpenWrite(fileName); + using var wr = new BinaryWriter(fs); + while (length > 0) + { + if (length < bufferSize) bufferSize = (int)length; + buffer = reader.ReadBytes(bufferSize); + wr.Write(length > bufferSize ? buffer : buffer.Take((int)length).ToArray()); + length -= bufferSize; + } + foreach (var path in fileData.GamePaths) + { + gamePathToFilePath[path] = fileName; + Logger.Verbose(path + " => " + fileName); + } + } + + return gamePathToFilePath; + } + + public void SaveMareCharaFile(CharacterCacheDto? dto, string description, string filePath) + { + CurrentlyWorking = true; + try + { + if (dto == null) return; + + var mareCharaFileData = _factory.Create(description, dto); + MareCharaFileHeader output = new(MareCharaFileHeader.CurrentVersion, mareCharaFileData); + + using var fs = new FileStream(filePath, FileMode.Create); + using var lz4 = new LZ4Stream(fs, LZ4StreamMode.Compress, LZ4StreamFlags.HighCompression); + using var writer = new BinaryWriter(lz4); + output.WriteToStream(writer); + var bufferSize = 4 * 1024 * 1024; + byte[] buffer = new byte[bufferSize]; + + if (dto.FileReplacements.TryGetValue(ObjectKind.Player, out var replacement)) + { + foreach (var file in replacement.Select(item => _manager.GetFileCacheByHash(item.Hash)).Where(file => file != null)) + { + var length = new FileInfo(file.ResolvedFilepath).Length; + using var fsRead = File.OpenRead(file.ResolvedFilepath); + using var br = new BinaryReader(fsRead); + int readBytes = 0; + while ((readBytes = br.Read(buffer, 0, bufferSize)) > 0) + { + writer.Write(readBytes == bufferSize ? buffer : buffer.Take(readBytes).ToArray()); + } + } + } + } + catch { throw; } + finally { CurrentlyWorking = false; } + } +} diff --git a/MareSynchronos/Managers/IpcManager.cs b/MareSynchronos/Managers/IpcManager.cs index 53193ee..3d72461 100644 --- a/MareSynchronos/Managers/IpcManager.cs +++ b/MareSynchronos/Managers/IpcManager.cs @@ -57,7 +57,10 @@ public class IpcManager : IDisposable private readonly ICallGateSubscriber _customizePlusOnScaleUpdate; private readonly DalamudUtil _dalamudUtil; - private readonly ConcurrentQueue actionQueue = new(); + private bool inGposeQueueMode = false; + private ConcurrentQueue actionQueue => inGposeQueueMode ? gposeActionQueue : normalQueue; + private readonly ConcurrentQueue normalQueue = new(); + private readonly ConcurrentQueue gposeActionQueue = new(); public IpcManager(DalamudPluginInterface pi, DalamudUtil dalamudUtil) { @@ -112,9 +115,25 @@ public class IpcManager : IDisposable _dalamudUtil = dalamudUtil; _dalamudUtil.FrameworkUpdate += HandleActionQueue; + _dalamudUtil.GposeFrameworkUpdate += HandleGposeActionQueue; _dalamudUtil.ZoneSwitchEnd += ClearActionQueue; } + private void HandleGposeActionQueue() + { + if (gposeActionQueue.TryDequeue(out var action)) + { + if (action == null) return; + Logger.Debug("Execution action in gpose queue: " + action.Method); + action(); + } + } + + public void ToggleGposeQueueMode(bool on) + { + inGposeQueueMode = on; + } + private void PenumbraModSettingChangedHandler() { PenumbraModSettingChanged?.Invoke(); @@ -123,6 +142,7 @@ public class IpcManager : IDisposable private void ClearActionQueue() { actionQueue.Clear(); + gposeActionQueue.Clear(); } private void ResourceLoaded(IntPtr ptr, string arg1, string arg2) @@ -223,6 +243,7 @@ public class IpcManager : IDisposable _dalamudUtil.FrameworkUpdate -= HandleActionQueue; _dalamudUtil.ZoneSwitchEnd -= ClearActionQueue; + _dalamudUtil.GposeFrameworkUpdate -= HandleGposeActionQueue; actionQueue.Clear(); _penumbraGameObjectResourcePathResolved.Dispose(); diff --git a/MareSynchronos/MareSynchronos.csproj b/MareSynchronos/MareSynchronos.csproj index 872832c..a42246b 100644 --- a/MareSynchronos/MareSynchronos.csproj +++ b/MareSynchronos/MareSynchronos.csproj @@ -3,7 +3,7 @@ - 0.6.8 + 0.6.9 https://github.com/Penumbra-Sync/client diff --git a/MareSynchronos/Plugin.cs b/MareSynchronos/Plugin.cs index 1f57089..cc2dbfd 100644 --- a/MareSynchronos/Plugin.cs +++ b/MareSynchronos/Plugin.cs @@ -15,6 +15,7 @@ using MareSynchronos.Utils; using Dalamud.Game.ClientState.Conditions; using MareSynchronos.FileCache; using Dalamud.Game.Gui; +using MareSynchronos.Export; namespace MareSynchronos; @@ -42,6 +43,8 @@ public sealed class Plugin : IDalamudPlugin private readonly UiShared _uiSharedComponent; private readonly Dalamud.Localization _localization; private readonly FileReplacementFactory _fileReplacementFactory; + private readonly MareCharaFileManager _mareCharaFileManager; + private readonly GposeUi _gposeUi; public Plugin(DalamudPluginInterface pluginInterface, CommandManager commandManager, @@ -50,6 +53,7 @@ public sealed class Plugin : IDalamudPlugin { Logger.Debug("Launching " + Name); _pluginInterface = pluginInterface; + _pluginInterface.UiBuilder.DisableGposeUiHide = true; _commandManager = commandManager; _configuration = _pluginInterface.GetPluginConfig() as Configuration ?? new Configuration(); _configuration.Initialize(_pluginInterface); @@ -69,11 +73,13 @@ public sealed class Plugin : IDalamudPlugin _apiController = new ApiController(_configuration, _dalamudUtil, _fileCacheManager); _periodicFileScanner = new PeriodicFileScanner(_ipcManager, _configuration, _fileCacheManager, _apiController, _dalamudUtil); _fileReplacementFactory = new FileReplacementFactory(_fileCacheManager, _ipcManager); + _mareCharaFileManager = new(_fileCacheManager, _ipcManager, _dalamudUtil); _uiSharedComponent = new UiShared(_ipcManager, _apiController, _periodicFileScanner, _fileDialogManager, _configuration, _dalamudUtil, _pluginInterface, _localization); - _settingsUi = new SettingsUi(_windowSystem, _uiSharedComponent, _configuration, _apiController); + _settingsUi = new SettingsUi(_windowSystem, _uiSharedComponent, _configuration, _apiController, _mareCharaFileManager); _compactUi = new CompactUi(_windowSystem, _uiSharedComponent, _configuration, _apiController); + _gposeUi = new GposeUi(_windowSystem, _mareCharaFileManager, _dalamudUtil, _fileDialogManager, _configuration); _introUi = new IntroUi(_windowSystem, _uiSharedComponent, _configuration, _periodicFileScanner); _settingsUi.SwitchToIntroUi += () => @@ -120,6 +126,7 @@ public sealed class Plugin : IDalamudPlugin _introUi?.Dispose(); _downloadUi?.Dispose(); _compactUi?.Dispose(); + _gposeUi?.Dispose(); _periodicFileScanner?.Dispose(); _fileCacheManager?.Dispose(); @@ -206,31 +213,35 @@ public sealed class Plugin : IDalamudPlugin private void OnCommand(string command, string args) { - var splitArgs = args.ToLowerInvariant().Trim().Split(" ", StringSplitOptions.RemoveEmptyEntries); + var splitArgs = args.ToLowerInvariant().Trim().Split(" ", StringSplitOptions.RemoveEmptyEntries); - if (splitArgs == null || splitArgs.Length == 0) + if (splitArgs == null || splitArgs.Length == 0) + { + // Interpret this as toggling the UI + OpenUi(); + return; + } + + if (splitArgs[0] == "toggle") + { + var fullPause = splitArgs.Length > 1 ? splitArgs[1] switch { - // Interpret this as toggling the UI - OpenUi(); - return; - } + "on" => false, + "off" => true, + _ => !_configuration.FullPause, + } : !_configuration.FullPause; - if (splitArgs[0] == "toggle") + if (fullPause != _configuration.FullPause) { - var fullPause = splitArgs.Length > 1 ? splitArgs[1] switch - { - "on" => false, - "off" => true, - _ => !_configuration.FullPause, - } : !_configuration.FullPause; - - if (fullPause != _configuration.FullPause) - { - _configuration.FullPause = fullPause; - _configuration.Save(); - _ = _apiController.CreateConnections(); - } + _configuration.FullPause = fullPause; + _configuration.Save(); + _ = _apiController.CreateConnections(); } + } + else if (splitArgs[0] == "gpose") + { + _gposeUi.Toggle(); + } } private void OpenUi() diff --git a/MareSynchronos/UI/CompactUI.cs b/MareSynchronos/UI/CompactUI.cs index b3f153c..e6329bf 100644 --- a/MareSynchronos/UI/CompactUI.cs +++ b/MareSynchronos/UI/CompactUI.cs @@ -40,6 +40,7 @@ public class CompactUi : Window, IDisposable public float TransferPartHeight = 0; public float _windowContentWidth = 0; private bool _showModalForUserAddition = false; + private bool _wasOpen = false; private bool showSyncShells = false; private GroupPanel groupPanel; @@ -85,6 +86,9 @@ public class CompactUi : Window, IDisposable _selectPairsForGroupUi = new(_tagHandler, configuration); _pairGroupsUi = new(_tagHandler, DrawPairedClient, apiController, _selectPairsForGroupUi); + _uiShared.GposeStart += UiShared_GposeStart; + _uiShared.GposeEnd += UiShared_GposeEnd; + SizeConstraints = new WindowSizeConstraints() { MinimumSize = new Vector2(350, 400), @@ -94,10 +98,23 @@ public class CompactUi : Window, IDisposable windowSystem.AddWindow(this); } + private void UiShared_GposeEnd() + { + IsOpen = _wasOpen; + } + + private void UiShared_GposeStart() + { + _wasOpen = IsOpen; + IsOpen = false; + } + public event SwitchUi? OpenSettingsUi; public void Dispose() { Logger.Verbose("Disposing " + nameof(CompactUi)); + _uiShared.GposeStart -= UiShared_GposeStart; + _uiShared.GposeEnd -= UiShared_GposeEnd; _windowSystem.RemoveWindow(this); } diff --git a/MareSynchronos/UI/DownloadUi.cs b/MareSynchronos/UI/DownloadUi.cs index f24ea2c..144836b 100644 --- a/MareSynchronos/UI/DownloadUi.cs +++ b/MareSynchronos/UI/DownloadUi.cs @@ -14,6 +14,7 @@ public class DownloadUi : Window, IDisposable private readonly Configuration _pluginConfiguration; private readonly ApiController _apiController; private readonly UiShared _uiShared; + private bool _wasOpen = false; public void Dispose() { @@ -52,6 +53,13 @@ public class DownloadUi : Window, IDisposable public override void PreDraw() { + if (_uiShared.IsInGpose) + { + _wasOpen = IsOpen; + IsOpen = false; + } + + base.PreDraw(); if (_uiShared.EditTrackerPosition) { diff --git a/MareSynchronos/UI/GposeUi.cs b/MareSynchronos/UI/GposeUi.cs new file mode 100644 index 0000000..77e45e1 --- /dev/null +++ b/MareSynchronos/UI/GposeUi.cs @@ -0,0 +1,87 @@ +using Dalamud.Interface; +using Dalamud.Interface.Colors; +using Dalamud.Interface.ImGuiFileDialog; +using Dalamud.Interface.Windowing; +using ImGuiNET; +using MareSynchronos.Export; +using MareSynchronos.Utils; +using System; +using System.Threading.Tasks; + +namespace MareSynchronos.UI; + +public class GposeUi : Window, IDisposable +{ + private readonly WindowSystem _windowSystem; + private readonly MareCharaFileManager _mareCharaFileManager; + private readonly DalamudUtil _dalamudUtil; + private readonly FileDialogManager _fileDialogManager; + private readonly Configuration _configuration; + + public GposeUi(WindowSystem windowSystem, MareCharaFileManager mareCharaFileManager, DalamudUtil dalamudUtil, FileDialogManager fileDialogManager, Configuration configuration) : base("Mare Synchronos Gpose Import UI###MareSynchronosGposeUI") + { + _windowSystem = windowSystem; + _mareCharaFileManager = mareCharaFileManager; + _dalamudUtil = dalamudUtil; + _fileDialogManager = fileDialogManager; + _configuration = configuration; + _dalamudUtil.GposeStart += StartGpose; + _dalamudUtil.GposeEnd += EndGpose; + IsOpen = _dalamudUtil.IsInGpose; + Flags = ImGuiWindowFlags.AlwaysAutoResize; + _windowSystem.AddWindow(this); + } + + private void EndGpose() + { + IsOpen = false; + _mareCharaFileManager.ClearMareCharaFile(); + } + + private void StartGpose() + { + IsOpen = _configuration.OpenGposeImportOnGposeStart; + } + + public void Dispose() + { + _dalamudUtil.GposeStart -= StartGpose; + _dalamudUtil.GposeEnd -= EndGpose; + _windowSystem.RemoveWindow(this); + } + + public override void Draw() + { + if (!_dalamudUtil.IsInGpose) IsOpen = false; + + if (!_mareCharaFileManager.CurrentlyWorking) + { + if (UiShared.IconTextButton(FontAwesomeIcon.FolderOpen, "Load MCDF")) + { + _fileDialogManager.OpenFileDialog("Pick MCDF file", ".mcdf", (success, path) => + { + if (!success) return; + + Task.Run(() => _mareCharaFileManager.LoadMareCharaFile(path)); + }); + } + UiShared.AttachToolTip("Applies it to the currently selected GPose actor"); + if (_mareCharaFileManager.LoadedCharaFile != null) + { + UiShared.TextWrapped("Loaded file: " + _mareCharaFileManager.LoadedCharaFile.FilePath); + UiShared.TextWrapped("File Description: " + _mareCharaFileManager.LoadedCharaFile.CharaFileData.Description); + if (UiShared.IconTextButton(FontAwesomeIcon.Check, "Apply loaded MCDF")) + { + Task.Run(async () => await _mareCharaFileManager.ApplyMareCharaFile(_dalamudUtil.GposeTargetGameObject).ConfigureAwait(false)); + } + UiShared.AttachToolTip("Applies it to the currently selected GPose actor"); + UiShared.ColorTextWrapped("Warning: redrawing or changing the character will revert all applied mods.", ImGuiColors.DalamudYellow); + } + } + else + { + UiShared.ColorTextWrapped("Loading Character...", ImGuiColors.DalamudYellow); + } + UiShared.TextWrapped("Hint: You can disable the automatic loading of this window in the Mare settings and open it manually with /mare gpose"); + } +} diff --git a/MareSynchronos/UI/IntroUI.cs b/MareSynchronos/UI/IntroUI.cs index 40f7774..ee8deca 100644 --- a/MareSynchronos/UI/IntroUI.cs +++ b/MareSynchronos/UI/IntroUI.cs @@ -62,6 +62,8 @@ internal class IntroUi : Window, IDisposable public override void Draw() { + if (_uiShared.IsInGpose) return; + if (!_pluginConfiguration.AcceptedAgreement && !_readFirstPage) { if (_uiShared.UidFontBuilt) ImGui.PushFont(_uiShared.UidFont); @@ -149,7 +151,7 @@ internal class IntroUi : Window, IDisposable || !Directory.Exists(_pluginConfiguration.CacheFolder))) { if (_uiShared.UidFontBuilt) ImGui.PushFont(_uiShared.UidFont); - ImGui.TextUnformatted("File Cache Setup"); + ImGui.TextUnformatted("File Storage Setup"); if (_uiShared.UidFontBuilt) ImGui.PopFont(); ImGui.Separator(); @@ -160,11 +162,12 @@ internal class IntroUi : Window, IDisposable else { UiShared.TextWrapped("To not unnecessary download files already present on your computer, Mare Synchronos will have to scan your Penumbra mod directory. " + - "Additionally, a local cache folder must be set where Mare Synchronos will download its local file cache to. " + - "Once the Cache Folder is set and the scan complete, this page will automatically forward to registration at a service."); + "Additionally, a local storage folder must be set where Mare Synchronos will download other character files to. " + + "Once the storage folder is set and the scan complete, this page will automatically forward to registration at a service."); UiShared.TextWrapped("Note: The initial scan, depending on the amount of mods you have, might take a while. Please wait until it is completed."); UiShared.ColorTextWrapped("Warning: once past this step you should not delete the FileCache.csv of Mare Synchronos in the Plugin Configurations folder of Dalamud. " + "Otherwise on the next launch a full re-scan of the file cache database will be initiated.", ImGuiColors.DalamudYellow); + UiShared.ColorTextWrapped("Warning: if the scan is hanging and does nothing for a long time, chances are high your Penumbra folder is not set up properly.", ImGuiColors.DalamudYellow); _uiShared.DrawCacheDirectorySetting(); } diff --git a/MareSynchronos/UI/SettingsUi.cs b/MareSynchronos/UI/SettingsUi.cs index 11046c0..f9450eb 100644 --- a/MareSynchronos/UI/SettingsUi.cs +++ b/MareSynchronos/UI/SettingsUi.cs @@ -13,6 +13,7 @@ using MareSynchronos.Utils; using MareSynchronos.WebAPI.Utils; using Dalamud.Utility; using Newtonsoft.Json; +using MareSynchronos.Export; namespace MareSynchronos.UI; @@ -22,6 +23,7 @@ public class SettingsUi : Window, IDisposable private readonly Configuration _configuration; private readonly WindowSystem _windowSystem; private readonly ApiController _apiController; + private readonly MareCharaFileManager _mareCharaFileManager; private readonly UiShared _uiShared; public CharacterCacheDto LastCreatedCharacterData { private get; set; } @@ -32,9 +34,11 @@ public class SettingsUi : Window, IDisposable private bool _openPopupOnAddition; private bool _hideInfoMessages; private bool _disableOptionalPluginsWarnings; + private bool _wasOpen = false; public SettingsUi(WindowSystem windowSystem, - UiShared uiShared, Configuration configuration, ApiController apiController) : base("Mare Synchronos Settings") + UiShared uiShared, Configuration configuration, ApiController apiController, + MareCharaFileManager mareCharaFileManager) : base("Mare Synchronos Settings") { Logger.Verbose("Creating " + nameof(SettingsUi)); @@ -47,17 +51,36 @@ public class SettingsUi : Window, IDisposable _configuration = configuration; _windowSystem = windowSystem; _apiController = apiController; + _mareCharaFileManager = mareCharaFileManager; _uiShared = uiShared; _openPopupOnAddition = _configuration.OpenPopupOnAdd; _hideInfoMessages = _configuration.HideInfoMessages; _disableOptionalPluginsWarnings = _configuration.DisableOptionalPluginWarnings; + + _uiShared.GposeStart += _uiShared_GposeStart; + _uiShared.GposeEnd += _uiShared_GposeEnd; + windowSystem.AddWindow(this); } + private void _uiShared_GposeEnd() + { + IsOpen = _wasOpen; + } + + private void _uiShared_GposeStart() + { + _wasOpen = IsOpen; + IsOpen = false; + } + public void Dispose() { Logger.Verbose("Disposing " + nameof(SettingsUi)); + _uiShared.GposeStart -= _uiShared_GposeStart; + _uiShared.GposeEnd -= _uiShared_GposeEnd; + _windowSystem.RemoveWindow(this); } @@ -87,9 +110,9 @@ public class SettingsUi : Window, IDisposable ImGui.EndTabItem(); } - if (ImGui.BeginTabItem("Cache Settings")) + if (ImGui.BeginTabItem("Export & Storage")) { - DrawFileCacheSettings(); + DrawFileStorageSettings(); ImGui.EndTabItem(); } @@ -140,6 +163,7 @@ public class SettingsUi : Window, IDisposable } _lastTab = "General"; + UiShared.FontText("Notes", _uiShared.UidFont); if (UiShared.IconTextButton(FontAwesomeIcon.StickyNote, "Export all your user notes to clipboard")) { ImGui.SetClipboardText(_uiShared.GetNotes()); @@ -162,7 +186,6 @@ public class SettingsUi : Window, IDisposable { UiShared.ColorTextWrapped("Attempt to import notes from clipboard failed. Check formatting and try again", ImGuiColors.DalamudRed); } - ImGui.Separator(); if (ImGui.Checkbox("Open Notes Popup on user addition", ref _openPopupOnAddition)) { _apiController.LastAddedUser = null; @@ -170,6 +193,9 @@ public class SettingsUi : Window, IDisposable _configuration.Save(); } UiShared.DrawHelpText("This will open a popup that allows you to set the notes for a user after successfully adding them to your individual pairs."); + + ImGui.Separator(); + UiShared.FontText("Server Messages", _uiShared.UidFont); if (ImGui.Checkbox("Hide Server Info Messages", ref _hideInfoMessages)) { _configuration.HideInfoMessages = _hideInfoMessages; @@ -520,7 +546,7 @@ public class SettingsUi : Window, IDisposable if (!_configuration.FullPause) { - UiShared.ColorTextWrapped("Note: to change servers you need to disconnect from your current Mare Synchronos server.", ImGuiColors.DalamudYellow); + UiShared.ColorTextWrapped("Note: to change servers or your secret key you need to disconnect from your current Mare Synchronos server.", ImGuiColors.DalamudYellow); } var marePaused = _configuration.FullPause; @@ -546,6 +572,10 @@ public class SettingsUi : Window, IDisposable _uiShared.DrawServiceSelection(() => { }); } + ImGui.Separator(); + + UiShared.FontText("Debug", _uiShared.UidFont); + if (UiShared.IconTextButton(FontAwesomeIcon.Copy, "[DEBUG] Copy Last created Character Data to clipboard")) { if (LastCreatedCharacterData != null) @@ -560,6 +590,9 @@ public class SettingsUi : Window, IDisposable UiShared.AttachToolTip("Use this when reporting mods being rejected from the server."); } + private string _charaFileSavePath = string.Empty; + private string _charaFileLoadPath = string.Empty; + private void DrawBlockedTransfers() { _lastTab = "BlockedTransfers"; @@ -675,15 +708,77 @@ public class SettingsUi : Window, IDisposable } } - private void DrawFileCacheSettings() + private bool _readExport = false; + private string _exportDescription = string.Empty; + + private void DrawFileStorageSettings() { _lastTab = "FileCache"; + + UiShared.FontText("Export MCDF", _uiShared.UidFont); + + UiShared.TextWrapped("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 unoffocial custom exporters to extract the containing data."); + + ImGui.Checkbox("##readExport", ref _readExport); + ImGui.SameLine(); + UiShared.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) + { + if (!_mareCharaFileManager.CurrentlyWorking) + { + ImGui.Indent(); + 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")) + { + _uiShared.FileDialogManager.SaveFileDialog("Export Character to file", ".mcdf", "export.mcdf", ".mcdf", (success, path) => + { + if (!success) return; + + Task.Run(() => + { + try + { + _mareCharaFileManager.SaveMareCharaFile(LastCreatedCharacterData, _exportDescription, path); + _exportDescription = string.Empty; + } + catch (Exception ex) + { + Logger.Error("Error saving data", ex); + } + }); + }); + } + ImGui.Unindent(); + } + else + { + UiShared.ColorTextWrapped("Export in progress", ImGuiColors.DalamudYellow); + } + } + bool openInGpose = _configuration.OpenGposeImportOnGposeStart; + if (ImGui.Checkbox("Open MCDF import window when GPose loads", ref openInGpose)) + { + _configuration.OpenGposeImportOnGposeStart = openInGpose; + _configuration.Save(); + } + UiShared.DrawHelpText("This will automatically open the import menu when loading into Gpose. If unchecked you can open the menu manually with /mare gpose"); + + + ImGui.Separator(); + + UiShared.FontText("Storage", _uiShared.UidFont); + + UiShared.TextWrapped("Mare stores downloaded files from paired people permanently. This is to improve loading performance and requiring less downloads. " + + "The storage governs itself by clearing data beyond the set storage size. Please set the storage size accordingly. It is not necessary to manually clear the storage."); + _uiShared.DrawFileScanState(); _uiShared.DrawTimeSpanBetweenScansSetting(); _uiShared.DrawCacheDirectorySetting(); - ImGui.Text($"Local cache size: {UiShared.ByteToString(_uiShared.FileCacheSize)}"); + ImGui.Text($"Local storage size: {UiShared.ByteToString(_uiShared.FileCacheSize)}"); ImGui.SameLine(); - if (ImGui.Button("Clear local cache")) + if (ImGui.Button("Clear local storage")) { if (UiShared.CtrlPressed()) { @@ -699,7 +794,7 @@ public class SettingsUi : Window, IDisposable } } UiShared.AttachToolTip("You normally do not need to do this. This will solely remove all downloaded data from all players and will require you to re-download everything again." + Environment.NewLine - + "Mares Cache is self-clearing and will not surpass the limit you have set it to." + Environment.NewLine + + "Mares storage is self-clearing and will not surpass the limit you have set it to." + Environment.NewLine + "If you still think you need to do this hold CTRL while pressing the button."); } diff --git a/MareSynchronos/UI/UIShared.cs b/MareSynchronos/UI/UIShared.cs index ded13fb..fe1fbee 100644 --- a/MareSynchronos/UI/UIShared.cs +++ b/MareSynchronos/UI/UIShared.cs @@ -28,7 +28,7 @@ public class UiShared : IDisposable private readonly IpcManager _ipcManager; private readonly ApiController _apiController; private readonly PeriodicFileScanner _cacheScanner; - private readonly FileDialogManager _fileDialogManager; + public readonly FileDialogManager FileDialogManager; private readonly Configuration _pluginConfiguration; private readonly DalamudUtil _dalamudUtil; private readonly DalamudPluginInterface _pluginInterface; @@ -39,6 +39,9 @@ public class UiShared : IDisposable public bool EditTrackerPosition { get; set; } public ImFontPtr UidFont { get; private set; } public bool UidFontBuilt { get; private set; } + public bool IsInGpose => _dalamudUtil.IsInGpose; + public event VoidDelegate? GposeStart; + public event VoidDelegate? GposeEnd; public static bool CtrlPressed() => (GetKeyState(0xA2) & 0x8000) != 0 || (GetKeyState(0xA3) & 0x8000) != 0; public static bool ShiftPressed() => (GetKeyState(0xA1) & 0x8000) != 0 || (GetKeyState(0xA0) & 0x8000) != 0; @@ -54,7 +57,7 @@ public class UiShared : IDisposable _ipcManager = ipcManager; _apiController = apiController; _cacheScanner = cacheScanner; - _fileDialogManager = fileDialogManager; + FileDialogManager = fileDialogManager; _pluginConfiguration = pluginConfiguration; _dalamudUtil = dalamudUtil; _pluginInterface = pluginInterface; @@ -63,6 +66,19 @@ public class UiShared : IDisposable _pluginInterface.UiBuilder.BuildFonts += BuildFont; _pluginInterface.UiBuilder.RebuildFonts(); + + _dalamudUtil.GposeStart += _dalamudUtil_GposeStart; + _dalamudUtil.GposeEnd += _dalamudUtil_GposeEnd; + } + + private void _dalamudUtil_GposeEnd() + { + GposeEnd?.Invoke(); + } + + private void _dalamudUtil_GposeStart() + { + GposeStart?.Invoke(); } public static float GetWindowContentRegionWidth() @@ -268,7 +284,7 @@ public class UiShared : IDisposable ImGui.TextUnformatted(text); ImGui.PopTextWrapPos(); } - + public static void FontText(string text, ImFontPtr font) { ImGui.PushFont(font); @@ -490,16 +506,16 @@ public class UiShared : IDisposable public void DrawCacheDirectorySetting() { - ColorTextWrapped("Note: The cache folder should be somewhere close to root (i.e. C:\\MareCache) in a new empty folder. DO NOT point this to your game folder. DO NOT point this to your Penumbra folder.", ImGuiColors.DalamudYellow); + ColorTextWrapped("Note: The storage folder should be somewhere close to root (i.e. C:\\MareStorage) in a new empty folder. DO NOT point this to your game folder. DO NOT point this to your Penumbra folder.", ImGuiColors.DalamudYellow); var cacheDirectory = _pluginConfiguration.CacheFolder; - ImGui.InputText("Cache Folder##cache", ref cacheDirectory, 255, ImGuiInputTextFlags.ReadOnly); + ImGui.InputText("Storage Folder##cache", ref cacheDirectory, 255, ImGuiInputTextFlags.ReadOnly); ImGui.SameLine(); ImGui.PushFont(UiBuilder.IconFont); string folderIcon = FontAwesomeIcon.Folder.ToIconString(); if (ImGui.Button(folderIcon + "##chooseCacheFolder")) { - _fileDialogManager.OpenFolderDialog("Pick Mare Synchronos Cache Folder", (success, path) => + FileDialogManager.OpenFolderDialog("Pick Mare Synchronos Storage Folder", (success, path) => { if (!success) return; @@ -525,7 +541,7 @@ public class UiShared : IDisposable if (_isPenumbraDirectory) { - ColorTextWrapped("Do not point the cache path directly to the Penumbra directory. If necessary, make a subfolder in it.", ImGuiColors.DalamudRed); + ColorTextWrapped("Do not point the storage path directly to the Penumbra directory. If necessary, make a subfolder in it.", ImGuiColors.DalamudRed); } else if (!_isDirectoryWritable) { @@ -533,7 +549,7 @@ public class UiShared : IDisposable } else if (_cacheDirectoryHasOtherFilesThanCache) { - ColorTextWrapped("Your selected directory has files inside that are not Mare related. Use an empty directory or a previous Mare cache directory only.", ImGuiColors.DalamudRed); + ColorTextWrapped("Your selected directory has files inside that are not Mare related. Use an empty directory or a previous Mare storage directory only.", ImGuiColors.DalamudRed); } else if (!_cacheDirectoryIsValidPath) { @@ -542,12 +558,12 @@ public class UiShared : IDisposable } float maxCacheSize = (float)_pluginConfiguration.MaxLocalCacheInGiB; - if (ImGui.SliderFloat("Maximum Cache Size in GiB", ref maxCacheSize, 1f, 200f, "%.2f GiB")) + if (ImGui.SliderFloat("Maximum Storage Size in GiB", ref maxCacheSize, 1f, 200f, "%.2f GiB")) { _pluginConfiguration.MaxLocalCacheInGiB = maxCacheSize; _pluginConfiguration.Save(); } - DrawHelpText("The cache is automatically governed by Mare. It will clear itself automatically once it reaches the set capacity by removing the oldest unused files. You typically do not need to clear it yourself."); + DrawHelpText("The storage is automatically governed by Mare. It will clear itself automatically once it reaches the set capacity by removing the oldest unused files. You typically do not need to clear it yourself."); } private bool _isDirectoryWritable = false; @@ -697,5 +713,7 @@ public class UiShared : IDisposable public void Dispose() { _pluginInterface.UiBuilder.BuildFonts -= BuildFont; + _dalamudUtil.GposeStart -= _dalamudUtil_GposeStart; + _dalamudUtil.GposeEnd -= _dalamudUtil_GposeEnd; } } diff --git a/MareSynchronos/Utils/DalamudUtil.cs b/MareSynchronos/Utils/DalamudUtil.cs index a10f77c..3fc589b 100644 --- a/MareSynchronos/Utils/DalamudUtil.cs +++ b/MareSynchronos/Utils/DalamudUtil.cs @@ -8,11 +8,14 @@ using Dalamud.Game.ClientState; using Dalamud.Game.ClientState.Conditions; using Dalamud.Game.ClientState.Objects; using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; using Dalamud.Game.Gui; using Dalamud.Game.Text.SeStringHandling; using FFXIVClientStructs.FFXIV.Client.Game.Character; +using FFXIVClientStructs.FFXIV.Client.Game.Control; using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject; + namespace MareSynchronos.Utils; public delegate void PlayerChange(Dalamud.Game.ClientState.Objects.Types.Character actor); @@ -35,8 +38,12 @@ public class DalamudUtil : IDisposable public event VoidDelegate? DelayedFrameworkUpdate; public event VoidDelegate? ZoneSwitchStart; public event VoidDelegate? ZoneSwitchEnd; + public event VoidDelegate? GposeStart; + public event VoidDelegate? GposeEnd; + public event VoidDelegate? GposeFrameworkUpdate; private DateTime _delayedFrameworkUpdateCheck = DateTime.Now; private bool _sentBetweenAreas = false; + public bool IsInGpose { get; private set; } = false; public unsafe bool IsGameObjectPresent(IntPtr key) { @@ -84,8 +91,21 @@ public class DalamudUtil : IDisposable _chatGui.Print(se.BuiltString); } - private void FrameworkOnUpdate(Framework framework) + private unsafe void FrameworkOnUpdate(Framework framework) { + if (GposeTarget != null && !IsInGpose) + { + Logger.Debug("Gpose start"); + IsInGpose = true; + GposeStart?.Invoke(); + } + else if (GposeTarget == null && IsInGpose) + { + Logger.Debug("Gpose end"); + IsInGpose = false; + GposeEnd?.Invoke(); + } + if (_condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51] || IsInGpose) { if (!_sentBetweenAreas) @@ -95,6 +115,8 @@ public class DalamudUtil : IDisposable ZoneSwitchStart?.Invoke(); } + if (IsInGpose) GposeFrameworkUpdate?.Invoke(); + return; } else if (_sentBetweenAreas) @@ -175,6 +197,10 @@ public class DalamudUtil : IDisposable return _objectTable.CreateObjectReference(reference); } + public unsafe GameObject* GposeTarget => TargetSystem.Instance()->GPoseTarget; + + public unsafe Dalamud.Game.ClientState.Objects.Types.GameObject? GposeTargetGameObject => GposeTarget == null ? null : _objectTable[GposeTarget->ObjectIndex]; + public bool IsLoggedIn { get; private set; } public bool IsPlayerPresent => _clientState.LocalPlayer != null && _clientState.LocalPlayer.IsValid(); @@ -186,21 +212,21 @@ public class DalamudUtil : IDisposable public unsafe IntPtr GetMinion() { - return (IntPtr)((Character*)PlayerPointer)->CompanionObject; + return (IntPtr)((FFXIVClientStructs.FFXIV.Client.Game.Character.Character*)PlayerPointer)->CompanionObject; } public unsafe IntPtr GetPet(IntPtr? playerPointer = null) { var mgr = CharacterManager.Instance(); if (playerPointer == null) playerPointer = PlayerPointer; - return (IntPtr)mgr->LookupPetByOwnerObject((BattleChara*)playerPointer); + return (IntPtr)mgr->LookupPetByOwnerObject((FFXIVClientStructs.FFXIV.Client.Game.Character.BattleChara*)playerPointer); } public unsafe IntPtr GetCompanion(IntPtr? playerPointer = null) { var mgr = CharacterManager.Instance(); if (playerPointer == null) playerPointer = PlayerPointer; - return (IntPtr)mgr->LookupBuddyByOwnerObject((BattleChara*)playerPointer); + return (IntPtr)mgr->LookupBuddyByOwnerObject((FFXIVClientStructs.FFXIV.Client.Game.Character.BattleChara*)playerPointer); } public string PlayerName => _clientState.LocalPlayer?.Name.ToString() ?? "--"; @@ -211,8 +237,6 @@ public class DalamudUtil : IDisposable public string PlayerNameHashed => Crypto.GetHash256(PlayerName + _clientState.LocalPlayer!.HomeWorld.Id); - public bool IsInGpose => _objectTable[201] != null; - public List GetPlayerCharacters() { return _objectTable.Where(obj => @@ -275,6 +299,30 @@ public class DalamudUtil : IDisposable Thread.Sleep(tick); } + public unsafe void DisableDraw(IntPtr characterAddress) + { + var obj = (GameObject*)characterAddress; + obj->DisableDraw(); + } + + public unsafe void WaitWhileGposeCharacterIsDrawing(IntPtr characterAddress, int timeOut = 5000) + { + Thread.Sleep(500); + var obj = (GameObject*)characterAddress; + const int tick = 250; + int curWaitTime = 0; + Logger.Verbose("RenderFlags:" + obj->RenderFlags.ToString("X")); + // ReSharper disable once LoopVariableIsNeverChangedInsideLoop + while (obj->RenderFlags != 0x00 && curWaitTime < timeOut) + { + Logger.Verbose($"Waiting for gpose actor to finish drawing"); + curWaitTime += tick; + Thread.Sleep(tick); + } + + Thread.Sleep(tick * 2); + } + public void Dispose() { _clientState.Login -= ClientStateOnLogin;