This commit is contained in:
Eauldane
2025-08-22 02:19:48 +01:00
commit a4c82452be
373 changed files with 52044 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
using Dalamud.Game.Text.SeStringHandling.Payloads;
using System.Security.Cryptography;
using System.Text;
namespace MareSynchronos.Utils;
public static class ChatUtils
{
// Based on https://git.anna.lgbt/anna/ExtraChat/src/branch/main/client/ExtraChat/Util/PayloadUtil.cs
// This must store a Guid (16 bytes), as Chat 2 converts the data back to one
public static RawPayload CreateExtraChatTagPayload(Guid guid)
{
var header = (byte[])[
0x02, // Payload.START_BYTE
0x27, // SeStringChunkType.Interactable
2 + 16, // remaining length: ExtraChat sends 19 here but I think its an error
0x20 // Custom ExtraChat InfoType
];
var footer = (byte)0x03; // Payload.END_BYTE
return new RawPayload([..header, ..guid.ToByteArray(), footer]);
}
// We have a unique identifier in the form of a GID, which can be consistently mapped to the same GUID
public static RawPayload CreateExtraChatTagPayload(string gid)
{
var gidBytes = UTF8Encoding.UTF8.GetBytes(gid);
var hashedBytes = MD5.HashData(gidBytes);
var guid = new Guid(hashedBytes);
return CreateExtraChatTagPayload(guid);
}
}

View File

@@ -0,0 +1,28 @@
using System.Security.Cryptography;
using System.Text;
namespace MareSynchronos.Utils;
public static class Crypto
{
#pragma warning disable SYSLIB0021 // Type or member is obsolete
private static readonly SHA256CryptoServiceProvider _sha256CryptoProvider = new();
public static string GetFileHash(this string filePath)
{
using SHA1CryptoServiceProvider cryptoProvider = new();
return BitConverter.ToString(cryptoProvider.ComputeHash(File.ReadAllBytes(filePath))).Replace("-", "", StringComparison.Ordinal);
}
public static string GetHash256(this string stringToHash)
{
return GetOrComputeHashSHA256(stringToHash);
}
private static string GetOrComputeHashSHA256(string stringToCompute)
{
return BitConverter.ToString(_sha256CryptoProvider.ComputeHash(Encoding.UTF8.GetBytes(stringToCompute))).Replace("-", "", StringComparison.Ordinal);
}
#pragma warning restore SYSLIB0021 // Type or member is obsolete
}

View File

@@ -0,0 +1,80 @@
using System.Security.Cryptography;
namespace MareSynchronos.Utils;
// Calculates the hash of content read or written to a stream
public class HashingStream : Stream
{
private readonly Stream _stream;
private readonly HashAlgorithm _hashAlgo;
private bool _finished = false;
public bool DisposeUnderlying { get; set; } = true;
public Stream UnderlyingStream { get => _stream; }
public HashingStream(Stream underlyingStream, HashAlgorithm hashAlgo)
{
_stream = underlyingStream;
_hashAlgo = hashAlgo;
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (!DisposeUnderlying)
return;
if (!_finished)
_stream.Dispose();
_hashAlgo.Dispose();
}
public override bool CanRead => _stream.CanRead;
public override bool CanSeek => false;
public override bool CanWrite => _stream.CanWrite;
public override long Length => _stream.Length;
public override long Position { get => _stream.Position; set => throw new NotSupportedException(); }
public override void Flush()
{
_stream.Flush();
}
public override int Read(byte[] buffer, int offset, int count)
{
ObjectDisposedException.ThrowIf(_finished, this);
int n = _stream.Read(buffer, offset, count);
if (n > 0)
_hashAlgo.TransformBlock(buffer, offset, n, buffer, offset);
return n;
}
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotSupportedException();
}
public override void SetLength(long value)
{
ObjectDisposedException.ThrowIf(_finished, this);
_stream.SetLength(value);
}
public override void Write(byte[] buffer, int offset, int count)
{
ObjectDisposedException.ThrowIf(_finished, this);
_stream.Write(buffer, offset, count);
_hashAlgo.TransformBlock(buffer, offset, count, buffer, offset);
}
public byte[] Finish()
{
if (_finished)
return _hashAlgo.Hash!;
_hashAlgo.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
_finished = true;
if (DisposeUnderlying)
_stream.Dispose();
return _hashAlgo.Hash!;
}
}

View File

@@ -0,0 +1,128 @@
namespace MareSynchronos.Utils;
// Limits the number of bytes read/written to an underlying stream
public class LimitedStream : Stream
{
private readonly Stream _stream;
private long _estimatedPosition = 0;
public long MaxPosition { get; private init; }
public bool DisposeUnderlying { get; set; } = true;
public Stream UnderlyingStream { get => _stream; }
public LimitedStream(Stream underlyingStream, long byteLimit)
{
_stream = underlyingStream;
try
{
_estimatedPosition = _stream.Position;
}
catch { }
MaxPosition = _estimatedPosition + byteLimit;
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (!DisposeUnderlying)
return;
_stream.Dispose();
}
public override bool CanRead => _stream.CanRead;
public override bool CanSeek => _stream.CanSeek;
public override bool CanWrite => _stream.CanWrite;
public override long Length => _stream.Length;
public override long Position { get => _stream.Position; set => _stream.Position = _estimatedPosition = value; }
public override void Flush()
{
_stream.Flush();
}
public override int Read(byte[] buffer, int offset, int count)
{
int remainder = (int)long.Clamp(MaxPosition - _estimatedPosition, 0, int.MaxValue);
if (count > remainder)
count = remainder;
int n = _stream.Read(buffer, offset, count);
_estimatedPosition += n;
return n;
}
public async override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
int remainder = (int)long.Clamp(MaxPosition - _estimatedPosition, 0, int.MaxValue);
if (count > remainder)
count = remainder;
#pragma warning disable CA1835
int n = await _stream.ReadAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false);
#pragma warning restore CA1835
_estimatedPosition += n;
return n;
}
public async override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
int remainder = (int)long.Clamp(MaxPosition - _estimatedPosition, 0, int.MaxValue);
if (buffer.Length > remainder)
buffer = buffer[..remainder];
int n = await _stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
_estimatedPosition += n;
return n;
}
public override long Seek(long offset, SeekOrigin origin)
{
long result = _stream.Seek(offset, origin);
_estimatedPosition = result;
return result;
}
public override void SetLength(long value)
{
_stream.SetLength(value);
}
public override void Write(byte[] buffer, int offset, int count)
{
int remainder = (int)long.Clamp(MaxPosition - _estimatedPosition, 0, int.MaxValue);
if (count > remainder)
count = remainder;
_stream.Write(buffer, offset, count);
_estimatedPosition += count;
}
public async override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
int remainder = (int)long.Clamp(MaxPosition - _estimatedPosition, 0, int.MaxValue);
if (count > remainder)
count = remainder;
#pragma warning disable CA1835
await _stream.WriteAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false);
#pragma warning restore CA1835
_estimatedPosition += count;
}
public async override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
int remainder = (int)long.Clamp(MaxPosition - _estimatedPosition, 0, int.MaxValue);
if (buffer.Length > remainder)
buffer = buffer[..remainder];
await _stream.WriteAsync(buffer, cancellationToken).ConfigureAwait(false);
_estimatedPosition += buffer.Length;
}
}

View File

@@ -0,0 +1,27 @@
using System.Runtime.CompilerServices;
using System.Text;
namespace MareSynchronos.Utils;
[InterpolatedStringHandler]
public readonly ref struct MareInterpolatedStringHandler
{
readonly StringBuilder _logMessageStringbuilder;
public MareInterpolatedStringHandler(int literalLength, int formattedCount)
{
_logMessageStringbuilder = new StringBuilder(literalLength);
}
public void AppendLiteral(string s)
{
_logMessageStringbuilder.Append(s);
}
public void AppendFormatted<T>(T t)
{
_logMessageStringbuilder.Append(t?.ToString());
}
public string BuildMessage() => _logMessageStringbuilder.ToString();
}

View File

@@ -0,0 +1,49 @@
namespace MareSynchronos.Utils;
public class PngHdr
{
private static readonly byte[] _magicSignature = [137, 80, 78, 71, 13, 10, 26, 10];
private static readonly byte[] _IHDR = [(byte)'I', (byte)'H', (byte)'D', (byte)'R'];
public static readonly (int Width, int Height) InvalidSize = (0, 0);
public static (int Width, int Height) TryExtractDimensions(Stream stream)
{
Span<byte> buffer = stackalloc byte[8];
try
{
stream.ReadExactly(buffer[..8]);
// All PNG files start with the same 8 bytes
if (!buffer.SequenceEqual(_magicSignature))
return InvalidSize;
stream.ReadExactly(buffer[..8]);
uint ihdrLength = BitConverter.ToUInt32(buffer);
// The next four bytes will be the length of the IHDR section (it should be 13 bytes but we only need 8)
if (ihdrLength < 8)
return InvalidSize;
// followed by ASCII "IHDR"
if (!buffer[4..].SequenceEqual(_IHDR))
return InvalidSize;
stream.ReadExactly(buffer[..8]);
uint width = BitConverter.ToUInt32(buffer);
uint height = BitConverter.ToUInt32(buffer[4..]);
// Validate the width/height are non-negative and... that's all we care about!
if (width > int.MaxValue || height > int.MaxValue)
return InvalidSize;
return ((int)width, (int)height);
}
catch (EndOfStreamException)
{
return InvalidSize;
}
}
}

View File

@@ -0,0 +1,47 @@
using System.Collections;
namespace MareSynchronos.Utils;
public class RollingList<T> : IEnumerable<T>
{
private readonly Lock _addLock = new();
private readonly LinkedList<T> _list = new();
public RollingList(int maximumCount)
{
if (maximumCount <= 0)
throw new ArgumentException(message: null, nameof(maximumCount));
MaximumCount = maximumCount;
}
public int Count => _list.Count;
public int MaximumCount { get; }
public T this[int index]
{
get
{
if (index < 0 || index >= Count)
throw new ArgumentOutOfRangeException(nameof(index));
return _list.Skip(index).First();
}
}
public void Add(T value)
{
lock (_addLock)
{
if (_list.Count == MaximumCount)
{
_list.RemoveFirst();
}
_list.AddLast(value);
}
}
public IEnumerator<T> GetEnumerator() => _list.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

View File

@@ -0,0 +1,22 @@
namespace MareSynchronos.Utils;
public class ValueProgress<T> : Progress<T>
{
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;
}
}

View File

@@ -0,0 +1,230 @@
using Dalamud.Game.ClientState.Objects.Types;
using MareSynchronos.API.Data;
using MareSynchronos.API.Data.Enum;
using MareSynchronos.PlayerData.Handlers;
using MareSynchronos.PlayerData.Pairs;
using Microsoft.Extensions.Logging;
using System.Text.Json;
namespace MareSynchronos.Utils;
public static class VariousExtensions
{
public static string ToByteString(this int bytes, bool addSuffix = true)
{
string[] suffix = ["B", "KiB", "MiB", "GiB", "TiB"];
int i;
double dblSByte = bytes;
for (i = 0; i < suffix.Length && bytes >= 1024; i++, bytes /= 1024)
{
dblSByte = bytes / 1024.0;
}
return addSuffix ? $"{dblSByte:0.00} {suffix[i]}" : $"{dblSByte:0.00}";
}
public static string ToByteString(this long bytes, bool addSuffix = true)
{
string[] suffix = ["B", "KiB", "MiB", "GiB", "TiB"];
int i;
double dblSByte = bytes;
for (i = 0; i < suffix.Length && bytes >= 1024; i++, bytes /= 1024)
{
dblSByte = bytes / 1024.0;
}
return addSuffix ? $"{dblSByte:0.00} {suffix[i]}" : $"{dblSByte:0.00}";
}
public static void CancelDispose(this CancellationTokenSource? cts)
{
try
{
cts?.Cancel();
cts?.Dispose();
}
catch (ObjectDisposedException)
{
// swallow it
}
}
public static CancellationTokenSource CancelRecreate(this CancellationTokenSource? cts)
{
cts?.CancelDispose();
return new CancellationTokenSource();
}
public static Dictionary<ObjectKind, HashSet<PlayerChanges>> CheckUpdatedData(this CharacterData newData, Guid applicationBase,
CharacterData? oldData, ILogger logger, PairHandler cachedPlayer, bool forceApplyCustomization, bool forceApplyMods)
{
oldData ??= new();
var charaDataToUpdate = new Dictionary<ObjectKind, HashSet<PlayerChanges>>();
foreach (ObjectKind objectKind in Enum.GetValues<ObjectKind>())
{
charaDataToUpdate[objectKind] = [];
oldData.FileReplacements.TryGetValue(objectKind, out var existingFileReplacements);
newData.FileReplacements.TryGetValue(objectKind, out var newFileReplacements);
oldData.GlamourerData.TryGetValue(objectKind, out var existingGlamourerData);
newData.GlamourerData.TryGetValue(objectKind, out var newGlamourerData);
bool hasNewButNotOldFileReplacements = newFileReplacements != null && existingFileReplacements == null;
bool hasOldButNotNewFileReplacements = existingFileReplacements != null && newFileReplacements == null;
bool hasNewButNotOldGlamourerData = newGlamourerData != null && existingGlamourerData == null;
bool hasOldButNotNewGlamourerData = existingGlamourerData != null && newGlamourerData == null;
bool hasNewAndOldFileReplacements = newFileReplacements != null && existingFileReplacements != null;
bool hasNewAndOldGlamourerData = newGlamourerData != null && existingGlamourerData != null;
if (hasNewButNotOldFileReplacements || hasOldButNotNewFileReplacements || hasNewButNotOldGlamourerData || hasOldButNotNewGlamourerData)
{
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Some new data arrived: NewButNotOldFiles:{hasNewButNotOldFileReplacements}," +
" OldButNotNewFiles:{hasOldButNotNewFileReplacements}, NewButNotOldGlam:{hasNewButNotOldGlamourerData}, OldButNotNewGlam:{hasOldButNotNewGlamourerData}) => {change}, {change2}",
applicationBase,
cachedPlayer, objectKind, hasNewButNotOldFileReplacements, hasOldButNotNewFileReplacements, hasNewButNotOldGlamourerData, hasOldButNotNewGlamourerData, PlayerChanges.ModFiles, PlayerChanges.Glamourer);
charaDataToUpdate[objectKind].Add(PlayerChanges.ModFiles);
charaDataToUpdate[objectKind].Add(PlayerChanges.Glamourer);
charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw);
}
else
{
if (hasNewAndOldFileReplacements)
{
bool listsAreEqual = oldData.FileReplacements[objectKind].SequenceEqual(newData.FileReplacements[objectKind], PlayerData.Data.FileReplacementDataComparer.Instance);
if (!listsAreEqual || forceApplyMods)
{
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (FileReplacements not equal) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModFiles);
charaDataToUpdate[objectKind].Add(PlayerChanges.ModFiles);
// XXX: This logic is disabled disabled because it seems to skip redrawing for something as basic as toggling a gear mod
#if false
if (forceApplyMods || objectKind != ObjectKind.Player)
{
charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw);
}
else
{
var existingFace = existingFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/face/", StringComparison.OrdinalIgnoreCase)))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
var existingHair = existingFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/hair/", StringComparison.OrdinalIgnoreCase)))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
var existingTail = existingFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/tail/", StringComparison.OrdinalIgnoreCase)))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
var newFace = newFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/face/", StringComparison.OrdinalIgnoreCase)))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
var newHair = newFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/hair/", StringComparison.OrdinalIgnoreCase)))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
var newTail = newFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/tail/", StringComparison.OrdinalIgnoreCase)))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
var existingTransients = existingFileReplacements.Where(g => g.GamePaths.Any(g => !g.EndsWith("mdl") && !g.EndsWith("tex") && !g.EndsWith("mtrl")))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
var newTransients = newFileReplacements.Where(g => g.GamePaths.Any(g => !g.EndsWith("mdl") && !g.EndsWith("tex") && !g.EndsWith("mtrl")))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
logger.LogTrace("[BASE-{appbase}] ExistingFace: {of}, NewFace: {fc}; ExistingHair: {eh}, NewHair: {nh}; ExistingTail: {et}, NewTail: {nt}; ExistingTransient: {etr}, NewTransient: {ntr}", applicationBase,
existingFace.Count, newFace.Count, existingHair.Count, newHair.Count, existingTail.Count, newTail.Count, existingTransients.Count, newTransients.Count);
var differentFace = !existingFace.SequenceEqual(newFace, PlayerData.Data.FileReplacementDataComparer.Instance);
var differentHair = !existingHair.SequenceEqual(newHair, PlayerData.Data.FileReplacementDataComparer.Instance);
var differentTail = !existingTail.SequenceEqual(newTail, PlayerData.Data.FileReplacementDataComparer.Instance);
var differenTransients = !existingTransients.SequenceEqual(newTransients, PlayerData.Data.FileReplacementDataComparer.Instance);
if (differentFace || differentHair || differentTail || differenTransients)
{
logger.LogDebug("[BASE-{appbase}] Different Subparts: Face: {face}, Hair: {hair}, Tail: {tail}, Transients: {transients} => {change}", applicationBase,
differentFace, differentHair, differentTail, differenTransients, PlayerChanges.ForcedRedraw);
charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw);
}
}
#endif
// XXX: Redraw on mod file changes always
charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw);
}
}
if (hasNewAndOldGlamourerData)
{
bool glamourerDataDifferent = !string.Equals(oldData.GlamourerData[objectKind], newData.GlamourerData[objectKind], StringComparison.Ordinal);
if (glamourerDataDifferent || forceApplyCustomization)
{
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Glamourer different) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Glamourer);
charaDataToUpdate[objectKind].Add(PlayerChanges.Glamourer);
}
}
}
oldData.CustomizePlusData.TryGetValue(objectKind, out var oldCustomizePlusData);
newData.CustomizePlusData.TryGetValue(objectKind, out var newCustomizePlusData);
oldCustomizePlusData ??= string.Empty;
newCustomizePlusData ??= string.Empty;
bool customizeDataDifferent = !string.Equals(oldCustomizePlusData, newCustomizePlusData, StringComparison.Ordinal);
if (customizeDataDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newCustomizePlusData)))
{
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff customize data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Customize);
charaDataToUpdate[objectKind].Add(PlayerChanges.Customize);
}
if (objectKind != ObjectKind.Player) continue;
bool manipDataDifferent = !string.Equals(oldData.ManipulationData, newData.ManipulationData, StringComparison.Ordinal);
if (manipDataDifferent || forceApplyMods)
{
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff manip data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModManip);
charaDataToUpdate[objectKind].Add(PlayerChanges.ModManip);
charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw);
}
bool heelsOffsetDifferent = !string.Equals(oldData.HeelsData, newData.HeelsData, StringComparison.Ordinal);
if (heelsOffsetDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newData.HeelsData)))
{
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff heels data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Heels);
charaDataToUpdate[objectKind].Add(PlayerChanges.Heels);
}
bool honorificDataDifferent = !string.Equals(oldData.HonorificData, newData.HonorificData, StringComparison.Ordinal);
if (honorificDataDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newData.HonorificData)))
{
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff honorific data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Honorific);
charaDataToUpdate[objectKind].Add(PlayerChanges.Honorific);
}
bool petNamesDataDifferent = !string.Equals(oldData.PetNamesData, newData.PetNamesData, StringComparison.Ordinal);
if (petNamesDataDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newData.PetNamesData)))
{
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff petnames data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.PetNames);
charaDataToUpdate[objectKind].Add(PlayerChanges.PetNames);
}
bool moodlesDataDifferent = !string.Equals(oldData.MoodlesData, newData.MoodlesData, StringComparison.Ordinal);
if (moodlesDataDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newData.MoodlesData)))
{
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff moodles data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Moodles);
charaDataToUpdate[objectKind].Add(PlayerChanges.Moodles);
}
}
foreach (KeyValuePair<ObjectKind, HashSet<PlayerChanges>> data in charaDataToUpdate.ToList())
{
if (!data.Value.Any()) charaDataToUpdate.Remove(data.Key);
else charaDataToUpdate[data.Key] = [.. data.Value.OrderByDescending(p => (int)p)];
}
return charaDataToUpdate;
}
public static T DeepClone<T>(this T obj)
{
return JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(obj))!;
}
public static unsafe int? ObjectTableIndex(this IGameObject? gameObject)
{
if (gameObject == null || gameObject.Address == IntPtr.Zero)
{
return null;
}
return ((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)gameObject.Address)->ObjectIndex;
}
}