diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8a4fdf2e3..0a6b44eeb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,6 +16,9 @@ jobs: fetch-depth: 0 - name: Setup MSBuild uses: microsoft/setup-msbuild@v1.0.2 + - uses: actions/setup-dotnet@v3 + with: + dotnet-version: '8.0.100' - name: Define VERSION run: | $env:COMMIT = $env:GITHUB_SHA.Substring(0, 7) diff --git a/.github/workflows/rollup.yml b/.github/workflows/rollup.yml index 44116e7b2..745231eac 100644 --- a/.github/workflows/rollup.yml +++ b/.github/workflows/rollup.yml @@ -11,8 +11,7 @@ jobs: strategy: matrix: branches: - - net8 - #- new_im_hooks # Unmergeable + - new_im_hooks defaults: run: diff --git a/Dalamud.Common/Dalamud.Common.csproj b/Dalamud.Common/Dalamud.Common.csproj index ac5d3fdba..594e09021 100644 --- a/Dalamud.Common/Dalamud.Common.csproj +++ b/Dalamud.Common/Dalamud.Common.csproj @@ -1,7 +1,7 @@ - net7.0 + net8.0 enable enable diff --git a/Dalamud.Common/Game/GameVersion.cs b/Dalamud.Common/Game/GameVersion.cs index 26ff0e48f..8bbcf891d 100644 --- a/Dalamud.Common/Game/GameVersion.cs +++ b/Dalamud.Common/Game/GameVersion.cs @@ -23,7 +23,6 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable class. /// /// Version string to parse. - [JsonConstructor] public GameVersion(string version) { var ver = Parse(version); @@ -42,20 +41,9 @@ public sealed class GameVersion : ICloneable, IComparable, IComparableThe day. /// The major version. /// The minor version. - public GameVersion(int year, int month, int day, int major, int minor) + [JsonConstructor] + public GameVersion(int year, int month, int day, int major, int minor) : this(year, month, day, major) { - if ((this.Year = year) < 0) - throw new ArgumentOutOfRangeException(nameof(year)); - - if ((this.Month = month) < 0) - throw new ArgumentOutOfRangeException(nameof(month)); - - if ((this.Day = day) < 0) - throw new ArgumentOutOfRangeException(nameof(day)); - - if ((this.Major = major) < 0) - throw new ArgumentOutOfRangeException(nameof(major)); - if ((this.Minor = minor) < 0) throw new ArgumentOutOfRangeException(nameof(minor)); } @@ -67,17 +55,8 @@ public sealed class GameVersion : ICloneable, IComparable, IComparableThe month. /// The day. /// The major version. - public GameVersion(int year, int month, int day, int major) + public GameVersion(int year, int month, int day, int major) : this(year, month, day) { - if ((this.Year = year) < 0) - throw new ArgumentOutOfRangeException(nameof(year)); - - if ((this.Month = month) < 0) - throw new ArgumentOutOfRangeException(nameof(month)); - - if ((this.Day = day) < 0) - throw new ArgumentOutOfRangeException(nameof(day)); - if ((this.Major = major) < 0) throw new ArgumentOutOfRangeException(nameof(major)); } @@ -88,14 +67,8 @@ public sealed class GameVersion : ICloneable, IComparable, IComparableThe year. /// The month. /// The day. - public GameVersion(int year, int month, int day) + public GameVersion(int year, int month, int day) : this(year, month) { - if ((this.Year = year) < 0) - throw new ArgumentOutOfRangeException(nameof(year)); - - if ((this.Month = month) < 0) - throw new ArgumentOutOfRangeException(nameof(month)); - if ((this.Day = day) < 0) throw new ArgumentOutOfRangeException(nameof(day)); } @@ -105,11 +78,8 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable /// The year. /// The month. - public GameVersion(int year, int month) + public GameVersion(int year, int month) : this(year) { - if ((this.Year = year) < 0) - throw new ArgumentOutOfRangeException(nameof(year)); - if ((this.Month = month) < 0) throw new ArgumentOutOfRangeException(nameof(month)); } @@ -139,26 +109,31 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable /// Gets the year component. /// + [JsonRequired] public int Year { get; } = -1; /// /// Gets the month component. /// + [JsonRequired] public int Month { get; } = -1; /// /// Gets the day component. /// + [JsonRequired] public int Day { get; } = -1; /// /// Gets the major version component. /// + [JsonRequired] public int Major { get; } = -1; /// /// Gets the minor version component. /// + [JsonRequired] public int Minor { get; } = -1; public static implicit operator GameVersion(string ver) @@ -183,17 +158,13 @@ public sealed class GameVersion : ICloneable, IComparable, IComparableGameVersion object. public static GameVersion Parse(string input) { - if (input == null) - throw new ArgumentNullException(nameof(input)); + ArgumentNullException.ThrowIfNull(input); if (input.ToLower(CultureInfo.InvariantCulture) == "any") - return new GameVersion(); + return Any; var parts = input.Split('.'); - var tplParts = parts.Select(p => - { - var result = int.TryParse(p, out var value); - return (result, value); - }).ToArray(); + var tplParts = parts.Select( + p => + { + var result = int.TryParse(p, out var value); + return (result, value); + }).ToArray(); if (tplParts.Any(t => !t.result)) throw new FormatException("Bad formatting"); @@ -259,18 +228,15 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable t.value).ToArray(); var len = intParts.Length; - if (len == 1) - return new GameVersion(intParts[0]); - else if (len == 2) - return new GameVersion(intParts[0], intParts[1]); - else if (len == 3) - return new GameVersion(intParts[0], intParts[1], intParts[2]); - else if (len == 4) - return new GameVersion(intParts[0], intParts[1], intParts[2], intParts[3]); - else if (len == 5) - return new GameVersion(intParts[0], intParts[1], intParts[2], intParts[3], intParts[4]); - else - throw new ArgumentException("Too many parts"); + return len switch + { + 1 => new GameVersion(intParts[0]), + 2 => new GameVersion(intParts[0], intParts[1]), + 3 => new GameVersion(intParts[0], intParts[1], intParts[2]), + 4 => new GameVersion(intParts[0], intParts[1], intParts[2], intParts[3]), + 5 => new GameVersion(intParts[0], intParts[1], intParts[2], intParts[3], intParts[4]), + _ => throw new ArgumentException("Too many parts"), + }; } /// @@ -299,17 +265,12 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable public int CompareTo(object? obj) { - if (obj == null) - return 1; - - if (obj is GameVersion value) + return obj switch { - return this.CompareTo(value); - } - else - { - throw new ArgumentException("Argument must be a GameVersion"); - } + null => 1, + GameVersion value => this.CompareTo(value), + _ => throw new ArgumentException("Argument must be a GameVersion", nameof(obj)), + }; } /// @@ -342,16 +303,14 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable value.Minor ? 1 : -1; + // This should never happen return 0; } /// public override bool Equals(object? obj) { - if (obj is not GameVersion value) - return false; - - return this.Equals(value); + return obj is GameVersion value && this.Equals(value); } /// @@ -373,16 +332,8 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable public override int GetHashCode() { - var accumulator = 0; - - // This might be horribly wrong, but it isn't used heavily. - accumulator |= this.Year.GetHashCode(); - accumulator |= this.Month.GetHashCode(); - accumulator |= this.Day.GetHashCode(); - accumulator |= this.Major.GetHashCode(); - accumulator |= this.Minor.GetHashCode(); - - return accumulator; + // https://learn.microsoft.com/en-us/dotnet/api/system.object.gethashcode?view=net-8.0#notes-to-inheritors + return HashCode.Combine(this.Year, this.Month, this.Day, this.Major, this.Minor); } /// @@ -396,11 +347,11 @@ public sealed class GameVersion : ICloneable, IComparable, IComparableThe calling serializer. public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) { - if (value == null) + switch (value) { - writer.WriteNull(); - } - else if (value is GameVersion) - { - writer.WriteValue(value.ToString()); - } - else - { - throw new JsonSerializationException("Expected GameVersion object value"); + case null: + writer.WriteNull(); + break; + case GameVersion: + writer.WriteValue(value.ToString()); + break; + default: + throw new JsonSerializationException("Expected GameVersion object value"); } } @@ -43,24 +42,20 @@ public sealed class GameVersionConverter : JsonConverter { return null; } - else + + if (reader.TokenType == JsonToken.String) { - if (reader.TokenType == JsonToken.String) + try { - try - { - return new GameVersion((string)reader.Value!); - } - catch (Exception ex) - { - throw new JsonSerializationException($"Error parsing GameVersion string: {reader.Value}", ex); - } + return new GameVersion((string)reader.Value!); } - else + catch (Exception ex) { - throw new JsonSerializationException($"Unexpected token or value when parsing GameVersion. Token: {reader.TokenType}, Value: {reader.Value}"); + throw new JsonSerializationException($"Error parsing GameVersion string: {reader.Value}", ex); } } + + throw new JsonSerializationException($"Unexpected token or value when parsing GameVersion. Token: {reader.TokenType}, Value: {reader.Value}"); } /// diff --git a/Dalamud.CorePlugin/Dalamud.CorePlugin.csproj b/Dalamud.CorePlugin/Dalamud.CorePlugin.csproj index bf315d99e..f9959a910 100644 --- a/Dalamud.CorePlugin/Dalamud.CorePlugin.csproj +++ b/Dalamud.CorePlugin/Dalamud.CorePlugin.csproj @@ -1,7 +1,7 @@ Dalamud.CorePlugin - net7.0-windows + net8.0-windows x64 10.0 true diff --git a/Dalamud.Injector/Dalamud.Injector.csproj b/Dalamud.Injector/Dalamud.Injector.csproj index d8a74e58d..1ff29ea66 100644 --- a/Dalamud.Injector/Dalamud.Injector.csproj +++ b/Dalamud.Injector/Dalamud.Injector.csproj @@ -1,7 +1,7 @@ - net7.0 + net8.0 win-x64 x64 x64;AnyCPU diff --git a/Dalamud.Test/Dalamud.Test.csproj b/Dalamud.Test/Dalamud.Test.csproj index 8f4ccf0dd..28e326238 100644 --- a/Dalamud.Test/Dalamud.Test.csproj +++ b/Dalamud.Test/Dalamud.Test.csproj @@ -1,11 +1,11 @@ - net7.0-windows + net8.0-windows win-x64 x64 x64;AnyCPU - 9.0 + 11.0 diff --git a/Dalamud.Test/Game/GameVersionConverterTests.cs b/Dalamud.Test/Game/GameVersionConverterTests.cs new file mode 100644 index 000000000..ac8c4c17d --- /dev/null +++ b/Dalamud.Test/Game/GameVersionConverterTests.cs @@ -0,0 +1,138 @@ +using Dalamud.Common.Game; + +using JetBrains.Annotations; + +using Newtonsoft.Json; + +using Xunit; + +namespace Dalamud.Test.Game; + +public class GameVersionConverterTests +{ + [Fact] + public void ReadJson_ConvertsFromString() + { + var serialized = """ + { + "Version": "2020.06.15.0000.0000" + } + """; + var deserialized = JsonConvert.DeserializeObject(serialized); + + Assert.NotNull(deserialized); + Assert.Equal(GameVersion.Parse("2020.06.15.0000.0000"), deserialized.Version); + } + + + [Fact] + public void ReadJson_ConvertsFromNull() + { + var serialized = """ + { + "Version": null + } + """; + var deserialized = JsonConvert.DeserializeObject(serialized); + + Assert.NotNull(deserialized); + Assert.Null(deserialized.Version); + } + + [Fact] + public void ReadJson_WhenInvalidType_Throws() + { + var serialized = """ + { + "Version": 2 + } + """; + Assert.Throws( + () => JsonConvert.DeserializeObject(serialized)); + } + + [Fact] + public void ReadJson_WhenInvalidVersion_Throws() + { + var serialized = """ + { + "Version": "junk" + } + """; + Assert.Throws( + () => JsonConvert.DeserializeObject(serialized)); + } + + [Fact] + public void WriteJson_ConvertsToString() + { + var deserialized = new TestSerializationClass + { + Version = GameVersion.Parse("2020.06.15.0000.0000"), + }; + var serialized = JsonConvert.SerializeObject(deserialized); + + Assert.Equal("""{"Version":"2020.06.15.0000.0000"}""", RemoveWhitespace(serialized)); + } + + [Fact] + public void WriteJson_ConvertsToNull() + { + var deserialized = new TestSerializationClass + { + Version = null, + }; + var serialized = JsonConvert.SerializeObject(deserialized); + + Assert.Equal("""{"Version":null}""", RemoveWhitespace(serialized)); + } + + [Fact] + public void WriteJson_WhenInvalidVersion_Throws() + { + var deserialized = new TestWrongTypeSerializationClass + { + Version = 42, + }; + Assert.Throws(() => JsonConvert.SerializeObject(deserialized)); + } + + [Fact] + public void CanConvert_WhenGameVersion_ReturnsTrue() + { + var converter = new GameVersionConverter(); + Assert.True(converter.CanConvert(typeof(GameVersion))); + } + + [Fact] + public void CanConvert_WhenNotGameVersion_ReturnsFalse() + { + var converter = new GameVersionConverter(); + Assert.False(converter.CanConvert(typeof(int))); + } + + [Fact] + public void CanConvert_WhenNull_ReturnsFalse() + { + var converter = new GameVersionConverter(); + Assert.False(converter.CanConvert(null!)); + } + + private static string RemoveWhitespace(string input) + { + return input.Replace(" ", "").Replace("\r", "").Replace("\n", ""); + } + + private class TestSerializationClass + { + [JsonConverter(typeof(GameVersionConverter))] + [CanBeNull] + public GameVersion Version { get; init; } + } + + private class TestWrongTypeSerializationClass + { + [JsonConverter(typeof(GameVersionConverter))] + public int Version { get; init; } + } +} diff --git a/Dalamud.Test/Game/GameVersionTests.cs b/Dalamud.Test/Game/GameVersionTests.cs index dcace4279..2a21350b4 100644 --- a/Dalamud.Test/Game/GameVersionTests.cs +++ b/Dalamud.Test/Game/GameVersionTests.cs @@ -1,10 +1,71 @@ +using System; + using Dalamud.Common.Game; + +using Newtonsoft.Json; + using Xunit; namespace Dalamud.Test.Game { public class GameVersionTests { + [Fact] + public void VersionComparisons() + { + var v1 = GameVersion.Parse("2021.01.01.0000.0000"); + var v2 = GameVersion.Parse("2021.01.01.0000.0000"); + Assert.True(v1 == v2); + Assert.False(v1 != v2); + Assert.False(v1 < v2); + Assert.True(v1 <= v2); + Assert.False(v1 > v2); + Assert.True(v1 >= v2); + } + + [Fact] + public void VersionAddition() + { + var v1 = GameVersion.Parse("2021.01.01.0000.0000"); + var v2 = GameVersion.Parse("2021.01.05.0000.0000"); + Assert.Equal(v2, v1 + TimeSpan.FromDays(4)); + } + + [Fact] + public void VersionAdditionAny() + { + Assert.Equal(GameVersion.Any, GameVersion.Any + TimeSpan.FromDays(4)); + } + + [Fact] + public void VersionSubtraction() + { + var v1 = GameVersion.Parse("2021.01.05.0000.0000"); + var v2 = GameVersion.Parse("2021.01.01.0000.0000"); + Assert.Equal(v2, v1 - TimeSpan.FromDays(4)); + } + + [Fact] + public void VersionSubtractionAny() + { + Assert.Equal(GameVersion.Any, GameVersion.Any - TimeSpan.FromDays(4)); + } + + [Fact] + public void VersionClone() + { + var v1 = GameVersion.Parse("2021.01.01.0000.0000"); + var v2 = v1.Clone(); + Assert.NotSame(v1, v2); + } + + [Fact] + public void VersionCast() + { + var v = GameVersion.Parse("2021.01.01.0000.0000"); + Assert.Equal("2021.01.01.0000.0000", v); + } + [Theory] [InlineData("any", "any")] [InlineData("2021.01.01.0000.0000", "2021.01.01.0000.0000")] @@ -14,6 +75,18 @@ namespace Dalamud.Test.Game var v2 = GameVersion.Parse(ver2); Assert.Equal(v1, v2); + Assert.Equal(0, v1.CompareTo(v2)); + Assert.Equal(v1.GetHashCode(), v2.GetHashCode()); + } + + [Fact] + public void VersionNullEquality() + { + // Tests `Equals(GameVersion? value)` + Assert.False(GameVersion.Parse("2021.01.01.0000.0000").Equals(null)); + + // Tests `Equals(object? value)` + Assert.False(GameVersion.Parse("2021.01.01.0000.0000").Equals((object)null)); } [Theory] @@ -31,6 +104,67 @@ namespace Dalamud.Test.Game Assert.True(v1.CompareTo(v2) < 0); } + [Theory] + [InlineData("any", "2020.06.15.0000.0000")] + public void VersionComparisonInverse(string ver1, string ver2) + { + var v1 = GameVersion.Parse(ver1); + var v2 = GameVersion.Parse(ver2); + + Assert.True(v1.CompareTo(v2) > 0); + } + + [Fact] + public void VersionComparisonNull() + { + var v = GameVersion.Parse("2020.06.15.0000.0000"); + + // Tests `CompareTo(GameVersion? value)` + Assert.True(v.CompareTo(null) > 0); + + // Tests `CompareTo(object? value)` + Assert.True(v.CompareTo((object)null) > 0); + } + + [Fact] + public void VersionComparisonBoxed() + { + var v1 = GameVersion.Parse("2020.06.15.0000.0000"); + var v2 = GameVersion.Parse("2020.06.15.0000.0000"); + Assert.Equal(0, v1.CompareTo((object)v2)); + } + + [Fact] + public void VersionComparisonBoxedInvalid() + { + var v = GameVersion.Parse("2020.06.15.0000.0000"); + Assert.Throws(() => v.CompareTo(42)); + } + + [Theory] + [InlineData("2020.06.15.0000.0000")] + [InlineData("2021.01.01.0000")] + [InlineData("2021.01.01")] + [InlineData("2021.01")] + [InlineData("2021")] + public void VersionParse(string ver) + { + var v = GameVersion.Parse(ver); + Assert.NotNull(v); + } + + [Theory] + [InlineData("2020.06.15.0000.0000")] + [InlineData("2021.01.01.0000")] + [InlineData("2021.01.01")] + [InlineData("2021.01")] + [InlineData("2021")] + public void VersionTryParse(string ver) + { + Assert.True(GameVersion.TryParse(ver, out var v)); + Assert.NotNull(v); + } + [Theory] [InlineData("2020.06.15.0000.0000")] [InlineData("2021.01.01.0000")] @@ -39,9 +173,8 @@ namespace Dalamud.Test.Game [InlineData("2021")] public void VersionConstructor(string ver) { - var v = GameVersion.Parse(ver); - - Assert.True(v != null); + var v = new GameVersion(ver); + Assert.NotNull(v); } [Theory] @@ -54,5 +187,89 @@ namespace Dalamud.Test.Game Assert.False(result); Assert.Null(v); } + + [Theory] + [InlineData("any", "any")] + [InlineData("2020.06.15.0000.0000", "2020.06.15.0000.0000")] + [InlineData("2021.01.01.0000", "2021.01.01.0000.0000")] + [InlineData("2021.01.01", "2021.01.01.0000.0000")] + [InlineData("2021.01", "2021.01.00.0000.0000")] + [InlineData("2021", "2021.00.00.0000.0000")] + public void VersionToString(string ver1, string ver2) + { + var v1 = GameVersion.Parse(ver1); + Assert.Equal(ver2, v1.ToString()); + } + + [Fact] + public void VersionIsSerializationSafe() + { + var v = GameVersion.Parse("2020.06.15.0000.0000"); + var serialized = JsonConvert.SerializeObject(v); + var deserialized = JsonConvert.DeserializeObject(serialized); + Assert.Equal(v, deserialized); + } + + [Fact] + public void VersionInvalidDeserialization() + { + var serialized = """ + { + "Year": -1, + "Month": -1, + "Day": -1, + "Major": -1, + "Minor": -1, + } + """; + Assert.Throws(() => JsonConvert.DeserializeObject(serialized)); + } + + [Fact] + public void VersionInvalidTypeDeserialization() + { + var serialized = """ + { + "Value": "Hello" + } + """; + Assert.Throws(() => JsonConvert.DeserializeObject(serialized)); + } + + [Fact] + public void VersionConstructorNegativeYear() + { + Assert.Throws(() => new GameVersion(-2024)); + } + + [Fact] + public void VersionConstructorNegativeMonth() + { + Assert.Throws(() => new GameVersion(2024, -3)); + } + + [Fact] + public void VersionConstructorNegativeDay() + { + Assert.Throws(() => new GameVersion(2024, 3, -13)); + } + + [Fact] + public void VersionConstructorNegativeMajor() + { + Assert.Throws(() => new GameVersion(2024, 3, 13, -1)); + } + + [Fact] + public void VersionConstructorNegativeMinor() + { + Assert.Throws(() => new GameVersion(2024, 3, 13, 0, -1)); + } + + [Fact] + public void VersionParseNull() + { + Assert.Throws(() => GameVersion.Parse(null!)); + } } } diff --git a/Dalamud.Test/Storage/ReliableFileStorageTests.cs b/Dalamud.Test/Storage/ReliableFileStorageTests.cs new file mode 100644 index 000000000..6cea81aec --- /dev/null +++ b/Dalamud.Test/Storage/ReliableFileStorageTests.cs @@ -0,0 +1,386 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +using Dalamud.Storage; + +using Xunit; + +namespace Dalamud.Test.Storage; + +public class ReliableFileStorageTests +{ + private const string DbFileName = "dalamudVfs.db"; + private const string TestFileName = "file.txt"; + private const string TestFileContent1 = "hello from señor dalamundo"; + private const string TestFileContent2 = "rewritten"; + + [Fact] + public async Task IsConcurrencySafe() + { + var dbDir = CreateTempDir(); + using var rfs = new ReliableFileStorage(dbDir); + + var tempFile = Path.Combine(CreateTempDir(), TestFileName); + + // Do reads/writes/deletes on the same file on many threads at once and + // see if anything throws + await Task.WhenAll( + Enumerable.Range(1, 6) + .Select( + i => Parallel.ForEachAsync( + Enumerable.Range(1, 100), + (j, _) => + { + if (i % 2 == 0) + { + // ReSharper disable once AccessToDisposedClosure + rfs.WriteAllText(tempFile, j.ToString()); + } + else if (i % 3 == 0) + { + try + { + // ReSharper disable once AccessToDisposedClosure + rfs.ReadAllText(tempFile); + } + catch (FileNotFoundException) + { + // this is fine + } + } + else + { + File.Delete(tempFile); + } + + return ValueTask.CompletedTask; + }))); + } + + [Fact] + public void Constructor_Dispose_Works() + { + var dbDir = CreateTempDir(); + var dbPath = Path.Combine(dbDir, DbFileName); + using var rfs = new ReliableFileStorage(dbDir); + + Assert.True(File.Exists(dbPath)); + } + + [Fact] + public void Exists_ThrowsIfPathIsEmpty() + { + using var rfs = CreateRfs(); + Assert.Throws(() => rfs.Exists("")); + } + + [Fact] + public void Exists_ThrowsIfPathIsNull() + { + using var rfs = CreateRfs(); + Assert.Throws(() => rfs.Exists(null!)); + } + + [Fact] + public void Exists_WhenFileMissing_ReturnsFalse() + { + var tempFile = Path.Combine(CreateTempDir(), TestFileName); + using var rfs = CreateRfs(); + + Assert.False(rfs.Exists(tempFile)); + } + + [Fact] + public void Exists_WhenFileMissing_WhenDbFailed_ReturnsFalse() + { + var tempFile = Path.Combine(CreateTempDir(), TestFileName); + using var rfs = CreateFailedRfs(); + + Assert.False(rfs.Exists(tempFile)); + } + + [Fact] + public async Task Exists_WhenFileOnDisk_ReturnsTrue() + { + var tempFile = Path.Combine(CreateTempDir(), TestFileName); + await File.WriteAllTextAsync(tempFile, TestFileContent1); + using var rfs = CreateRfs(); + + Assert.True(rfs.Exists(tempFile)); + } + + [Fact] + public void Exists_WhenFileInBackup_ReturnsTrue() + { + var tempFile = Path.Combine(CreateTempDir(), TestFileName); + using var rfs = CreateRfs(); + + rfs.WriteAllText(tempFile, TestFileContent1); + + File.Delete(tempFile); + Assert.True(rfs.Exists(tempFile)); + } + + [Fact] + public void Exists_WhenFileInBackup_WithDifferentContainerId_ReturnsFalse() + { + var tempFile = Path.Combine(CreateTempDir(), TestFileName); + using var rfs = CreateRfs(); + + rfs.WriteAllText(tempFile, TestFileContent1); + + File.Delete(tempFile); + Assert.False(rfs.Exists(tempFile, Guid.NewGuid())); + } + + [Fact] + public void WriteAllText_ThrowsIfPathIsEmpty() + { + using var rfs = CreateRfs(); + Assert.Throws(() => rfs.WriteAllText("", TestFileContent1)); + } + + [Fact] + public void WriteAllText_ThrowsIfPathIsNull() + { + using var rfs = CreateRfs(); + Assert.Throws(() => rfs.WriteAllText(null!, TestFileContent1)); + } + + [Fact] + public async Task WriteAllText_WritesToDbAndDisk() + { + var tempFile = Path.Combine(CreateTempDir(), TestFileName); + using var rfs = CreateRfs(); + + rfs.WriteAllText(tempFile, TestFileContent1); + + Assert.True(File.Exists(tempFile)); + Assert.Equal(TestFileContent1, rfs.ReadAllText(tempFile, forceBackup: true)); + Assert.Equal(TestFileContent1, await File.ReadAllTextAsync(tempFile)); + } + + [Fact] + public void WriteAllText_SeparatesContainers() + { + var tempFile = Path.Combine(CreateTempDir(), TestFileName); + var containerId = Guid.NewGuid(); + + using var rfs = CreateRfs(); + rfs.WriteAllText(tempFile, TestFileContent1); + rfs.WriteAllText(tempFile, TestFileContent2, containerId); + File.Delete(tempFile); + + Assert.Equal(TestFileContent1, rfs.ReadAllText(tempFile, forceBackup: true)); + Assert.Equal(TestFileContent2, rfs.ReadAllText(tempFile, forceBackup: true, containerId)); + } + + [Fact] + public async Task WriteAllText_WhenDbFailed_WritesToDisk() + { + var tempFile = Path.Combine(CreateTempDir(), TestFileName); + using var rfs = CreateFailedRfs(); + + rfs.WriteAllText(tempFile, TestFileContent1); + + Assert.True(File.Exists(tempFile)); + Assert.Equal(TestFileContent1, await File.ReadAllTextAsync(tempFile)); + } + + [Fact] + public async Task WriteAllText_CanUpdateExistingFile() + { + var tempFile = Path.Combine(CreateTempDir(), TestFileName); + using var rfs = CreateRfs(); + + rfs.WriteAllText(tempFile, TestFileContent1); + rfs.WriteAllText(tempFile, TestFileContent2); + + Assert.True(File.Exists(tempFile)); + Assert.Equal(TestFileContent2, rfs.ReadAllText(tempFile, forceBackup: true)); + Assert.Equal(TestFileContent2, await File.ReadAllTextAsync(tempFile)); + } + + [Fact] + public void WriteAllText_SupportsNullContent() + { + var tempFile = Path.Combine(CreateTempDir(), TestFileName); + using var rfs = CreateRfs(); + + rfs.WriteAllText(tempFile, null); + + Assert.True(File.Exists(tempFile)); + Assert.Equal("", rfs.ReadAllText(tempFile)); + } + + [Fact] + public void ReadAllText_ThrowsIfPathIsEmpty() + { + using var rfs = CreateRfs(); + Assert.Throws(() => rfs.ReadAllText("")); + } + + [Fact] + public void ReadAllText_ThrowsIfPathIsNull() + { + using var rfs = CreateRfs(); + Assert.Throws(() => rfs.ReadAllText(null!)); + } + + [Fact] + public async Task ReadAllText_WhenFileOnDisk_ReturnsContent() + { + var tempFile = Path.Combine(CreateTempDir(), TestFileName); + await File.WriteAllTextAsync(tempFile, TestFileContent1); + using var rfs = CreateRfs(); + + Assert.Equal(TestFileContent1, rfs.ReadAllText(tempFile)); + } + + [Fact] + public void ReadAllText_WhenFileMissingWithBackup_ReturnsContent() + { + var tempFile = Path.Combine(CreateTempDir(), TestFileName); + using var rfs = CreateRfs(); + + rfs.WriteAllText(tempFile, TestFileContent1); + File.Delete(tempFile); + + Assert.Equal(TestFileContent1, rfs.ReadAllText(tempFile)); + } + + [Fact] + public void ReadAllText_WhenFileMissingWithBackup_ThrowsWithDifferentContainerId() + { + var tempFile = Path.Combine(CreateTempDir(), TestFileName); + var containerId = Guid.NewGuid(); + using var rfs = CreateRfs(); + + rfs.WriteAllText(tempFile, TestFileContent1); + File.Delete(tempFile); + + Assert.Throws(() => rfs.ReadAllText(tempFile, containerId: containerId)); + } + + [Fact] + public void ReadAllText_WhenFileMissing_ThrowsIfDbFailed() + { + var tempFile = Path.Combine(CreateTempDir(), TestFileName); + using var rfs = CreateFailedRfs(); + Assert.Throws(() => rfs.ReadAllText(tempFile)); + } + + [Fact] + public async Task ReadAllText_WithReader_WhenFileOnDisk_ReadsContent() + { + var tempFile = Path.Combine(CreateTempDir(), TestFileName); + await File.WriteAllTextAsync(tempFile, TestFileContent1); + using var rfs = CreateRfs(); + rfs.ReadAllText(tempFile, text => Assert.Equal(TestFileContent1, text)); + } + + [Fact] + public async Task ReadAllText_WithReader_WhenReaderThrows_ThrowsIfBackupMissing() + { + var tempFile = Path.Combine(CreateTempDir(), TestFileName); + await File.WriteAllTextAsync(tempFile, TestFileContent1); + + var readerCalledOnce = false; + + using var rfs = CreateRfs(); + Assert.Throws(() => rfs.ReadAllText(tempFile, Reader)); + + return; + + void Reader(string text) + { + var wasReaderCalledOnce = readerCalledOnce; + readerCalledOnce = true; + if (!wasReaderCalledOnce) throw new Exception(); + } + } + + [Fact] + public void ReadAllText_WithReader_WhenReaderThrows_ReadsContentFromBackup() + { + var tempFile = Path.Combine(CreateTempDir(), TestFileName); + + var readerCalledOnce = false; + var assertionCalled = false; + + using var rfs = CreateRfs(); + rfs.WriteAllText(tempFile, TestFileContent1); + File.Delete(tempFile); + + rfs.ReadAllText(tempFile, Reader); + Assert.True(assertionCalled); + + return; + + void Reader(string text) + { + var wasReaderCalledOnce = readerCalledOnce; + readerCalledOnce = true; + if (!wasReaderCalledOnce) throw new Exception(); + Assert.Equal(TestFileContent1, text); + assertionCalled = true; + } + } + + [Fact] + public async Task ReadAllText_WithReader_RethrowsFileNotFoundException() + { + var tempFile = Path.Combine(CreateTempDir(), TestFileName); + await File.WriteAllTextAsync(tempFile, TestFileContent1); + using var rfs = CreateRfs(); + Assert.Throws(() => rfs.ReadAllText(tempFile, _ => throw new FileNotFoundException())); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ReadAllText_WhenFileDoesNotExist_Throws(bool forceBackup) + { + var tempFile = Path.Combine(CreateTempDir(), TestFileName); + using var rfs = CreateRfs(); + Assert.Throws(() => rfs.ReadAllText(tempFile, forceBackup)); + } + + private static ReliableFileStorage CreateRfs() + { + var dbDir = CreateTempDir(); + return new ReliableFileStorage(dbDir); + } + + private static ReliableFileStorage CreateFailedRfs() + { + var dbDir = CreateTempDir(); + var dbPath = Path.Combine(dbDir, DbFileName); + + // Create a corrupt DB deliberately, and hold its handle until + // the end of the scope + using var f = File.Open(dbPath, FileMode.CreateNew); + f.Write("broken"u8); + + // Throws an SQLiteException initially, and then throws an + // IOException when attempting to delete the file because + // there's already an active handle associated with it + return new ReliableFileStorage(dbDir); + } + + private static string CreateTempDir() + { + string tempDir; + do + { + // Generate temp directories until we get a new one (usually happens on the first try) + tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + } + while (File.Exists(tempDir)); + + Directory.CreateDirectory(tempDir); + return tempDir; + } +} diff --git a/Dalamud/Configuration/Internal/DalamudConfiguration.cs b/Dalamud/Configuration/Internal/DalamudConfiguration.cs index 70ed5dfde..9159f042c 100644 --- a/Dalamud/Configuration/Internal/DalamudConfiguration.cs +++ b/Dalamud/Configuration/Internal/DalamudConfiguration.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using Dalamud.Game.Text; using Dalamud.Interface.FontIdentifier; @@ -367,6 +369,11 @@ internal sealed class DalamudConfiguration : IInternalDisposableService /// public bool ShowTsm { get; set; } = true; + /// + /// Gets or sets a value indicating whether to reduce motions (animations). + /// + public bool? ReduceMotions { get; set; } + /// /// Gets or sets a value indicating whether or not market board data should be uploaded. /// @@ -481,6 +488,15 @@ internal sealed class DalamudConfiguration : IInternalDisposableService deserialized ??= new DalamudConfiguration(); deserialized.configPath = path; + + try + { + deserialized.SetDefaults(); + } + catch (Exception e) + { + Log.Error(e, "Failed to set defaults for DalamudConfiguration"); + } return deserialized; } @@ -522,6 +538,31 @@ internal sealed class DalamudConfiguration : IInternalDisposableService } } + private void SetDefaults() + { + // "Reduced motion" + if (!this.ReduceMotions.HasValue) + { + // https://source.chromium.org/chromium/chromium/src/+/main:ui/gfx/animation/animation_win.cc;l=29?q=ReducedMotion&ss=chromium + var winAnimEnabled = 0; + var success = NativeFunctions.SystemParametersInfo( + (uint)NativeFunctions.AccessibilityParameter.SPI_GETCLIENTAREAANIMATION, + 0, + ref winAnimEnabled, + 0); + + if (!success) + { + Log.Warning("Failed to get Windows animation setting, assuming reduced motion is off (GetLastError: {GetLastError:X})", Marshal.GetLastPInvokeError()); + this.ReduceMotions = false; + } + else + { + this.ReduceMotions = winAnimEnabled == 0; + } + } + } + private void Save() { ThreadSafety.AssertMainThread(); diff --git a/Dalamud/Dalamud.csproj b/Dalamud/Dalamud.csproj index 7e166d8b3..5bf4ecc39 100644 --- a/Dalamud/Dalamud.csproj +++ b/Dalamud/Dalamud.csproj @@ -1,14 +1,14 @@ - net7.0-windows + net8.0-windows x64 x64;AnyCPU - 11.0 + 12.0 - 9.0.0.21 + 9.1.0.0 XIV Launcher addon framework $(DalamudVersion) $(DalamudVersion) @@ -75,7 +75,6 @@ all - @@ -119,14 +118,6 @@ - - - - monomod - - - - $(OutputPath)TEMP_gitver.txt diff --git a/Dalamud/Game/ClientState/Objects/ObjectTable.cs b/Dalamud/Game/ClientState/Objects/ObjectTable.cs index 278c0772f..9b42e1024 100644 --- a/Dalamud/Game/ClientState/Objects/ObjectTable.cs +++ b/Dalamud/Game/ClientState/Objects/ObjectTable.cs @@ -1,15 +1,22 @@ -using System; using System.Collections; using System.Collections.Generic; +using System.Runtime.CompilerServices; using Dalamud.Game.ClientState.Objects.Enums; using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Objects.Types; using Dalamud.IoC; using Dalamud.IoC.Internal; +using Dalamud.Plugin.Internal; using Dalamud.Plugin.Services; +using Dalamud.Utility; + +using Microsoft.Extensions.ObjectPool; + using Serilog; +using CSGameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject; + namespace Dalamud.Game.ClientState.Objects; /// @@ -25,18 +32,41 @@ internal sealed partial class ObjectTable : IServiceType, IObjectTable { private const int ObjectTableLength = 599; - private readonly ClientStateAddressResolver address; + private readonly ClientState clientState; + private readonly CachedEntry[] cachedObjectTable = new CachedEntry[ObjectTableLength]; + + private readonly ObjectPool multiThreadedEnumerators = + new DefaultObjectPoolProvider().Create(); + + private readonly Enumerator?[] frameworkThreadEnumerators = new Enumerator?[4]; + + private long nextMultithreadedUsageWarnTime; [ServiceManager.ServiceConstructor] - private ObjectTable(ClientState clientState) + private unsafe ObjectTable(ClientState clientState) { - this.address = clientState.AddressResolver; + this.clientState = clientState; - Log.Verbose($"Object table address 0x{this.address.ObjectTable.ToInt64():X}"); + var nativeObjectTableAddress = (CSGameObject**)this.clientState.AddressResolver.ObjectTable; + for (var i = 0; i < this.cachedObjectTable.Length; i++) + this.cachedObjectTable[i] = new(nativeObjectTableAddress, i); + + for (var i = 0; i < this.frameworkThreadEnumerators.Length; i++) + this.frameworkThreadEnumerators[i] = new(this, i); + + Log.Verbose($"Object table address 0x{this.clientState.AddressResolver.ObjectTable.ToInt64():X}"); } /// - public IntPtr Address => this.address.ObjectTable; + public nint Address + { + get + { + _ = this.WarnMultithreadedUsage(); + + return this.clientState.AddressResolver.ObjectTable; + } + } /// public int Length => ObjectTableLength; @@ -46,50 +76,49 @@ internal sealed partial class ObjectTable : IServiceType, IObjectTable { get { - var address = this.GetObjectAddress(index); - return this.CreateObjectReference(address); + _ = this.WarnMultithreadedUsage(); + + return index is >= ObjectTableLength or < 0 ? null : this.cachedObjectTable[index].Update(); } } /// public GameObject? SearchById(ulong objectId) { + _ = this.WarnMultithreadedUsage(); + if (objectId is GameObject.InvalidGameObjectId or 0) return null; - foreach (var obj in this) + foreach (var e in this.cachedObjectTable) { - if (obj == null) - continue; - - if (obj.ObjectId == objectId) - return obj; + if (e.Update() is { } o && o.ObjectId == objectId) + return o; } return null; } /// - public unsafe IntPtr GetObjectAddress(int index) + public unsafe nint GetObjectAddress(int index) { - if (index < 0 || index >= ObjectTableLength) - return IntPtr.Zero; + _ = this.WarnMultithreadedUsage(); - return *(IntPtr*)(this.address.ObjectTable + (8 * index)); + return index is < 0 or >= ObjectTableLength ? nint.Zero : (nint)this.cachedObjectTable[index].Address; } /// - public unsafe GameObject? CreateObjectReference(IntPtr address) + public unsafe GameObject? CreateObjectReference(nint address) { - var clientState = Service.GetNullable(); + _ = this.WarnMultithreadedUsage(); - if (clientState == null || clientState.LocalContentId == 0) + if (this.clientState.LocalContentId == 0) return null; - if (address == IntPtr.Zero) + if (address == nint.Zero) return null; - var obj = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)address; + var obj = (CSGameObject*)address; var objKind = (ObjectKind)obj->ObjectKind; return objKind switch { @@ -104,6 +133,82 @@ internal sealed partial class ObjectTable : IServiceType, IObjectTable _ => new GameObject(address), }; } + + [Api10ToDo("Use ThreadSafety.AssertMainThread() instead of this.")] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool WarnMultithreadedUsage() + { + if (ThreadSafety.IsMainThread) + return false; + + var n = Environment.TickCount64; + if (this.nextMultithreadedUsageWarnTime < n) + { + this.nextMultithreadedUsageWarnTime = n + 30000; + + Log.Warning( + "{plugin} is accessing {objectTable} outside the main thread. This is deprecated.", + Service.Get().FindCallingPlugin()?.Name ?? "", + nameof(ObjectTable)); + } + + return true; + } + + /// Stores an object table entry, with preallocated concrete types. + internal readonly unsafe struct CachedEntry + { + private readonly CSGameObject** gameObjectPtrPtr; + private readonly PlayerCharacter playerCharacter; + private readonly BattleNpc battleNpc; + private readonly Npc npc; + private readonly EventObj eventObj; + private readonly GameObject gameObject; + + /// Initializes a new instance of the struct. + /// The object table that this entry should be pointing to. + /// The slot index inside the table. + public CachedEntry(CSGameObject** ownerTable, int slot) + { + this.gameObjectPtrPtr = ownerTable + slot; + this.playerCharacter = new(nint.Zero); + this.battleNpc = new(nint.Zero); + this.npc = new(nint.Zero); + this.eventObj = new(nint.Zero); + this.gameObject = new(nint.Zero); + } + + /// Gets the address of the underlying native object. May be null. + public CSGameObject* Address + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => *this.gameObjectPtrPtr; + } + + /// Updates and gets the wrapped game object pointed by this struct. + /// The pointed object, or null if no object exists at that slot. + public GameObject? Update() + { + var address = this.Address; + if (address is null) + return null; + + var activeObject = (ObjectKind)address->ObjectKind switch + { + ObjectKind.Player => this.playerCharacter, + ObjectKind.BattleNpc => this.battleNpc, + ObjectKind.EventNpc => this.npc, + ObjectKind.Retainer => this.npc, + ObjectKind.EventObj => this.eventObj, + ObjectKind.Companion => this.npc, + ObjectKind.MountType => this.npc, + ObjectKind.Ornament => this.npc, + _ => this.gameObject, + }; + activeObject.Address = (nint)address; + return activeObject; + } + } } /// @@ -117,17 +222,90 @@ internal sealed partial class ObjectTable /// public IEnumerator GetEnumerator() { - for (var i = 0; i < ObjectTableLength; i++) + // If something's trying to enumerate outside the framework thread, we use the ObjectPool. + if (this.WarnMultithreadedUsage()) { - var obj = this[i]; - - if (obj == null) - continue; - - yield return obj; + // let's not + var e = this.multiThreadedEnumerators.Get(); + e.InitializeForPooledObjects(this); + return e; } + + // If we're on the framework thread, see if there's an already allocated enumerator available for use. + foreach (ref var x in this.frameworkThreadEnumerators.AsSpan()) + { + if (x is not null) + { + var t = x; + x = null; + t.Reset(); + return t; + } + } + + // No reusable enumerator is available; allocate a new temporary one. + return new Enumerator(this, -1); } /// IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + + private sealed class Enumerator : IEnumerator, IResettable + { + private readonly int slotId; + private ObjectTable? owner; + + private int index = -1; + + public Enumerator() => this.slotId = -1; + + public Enumerator(ObjectTable owner, int slotId) + { + this.owner = owner; + this.slotId = slotId; + } + + public GameObject Current { get; private set; } = null!; + + object IEnumerator.Current => this.Current; + + public bool MoveNext() + { + if (this.index == ObjectTableLength) + return false; + + var cache = this.owner!.cachedObjectTable.AsSpan(); + for (this.index++; this.index < ObjectTableLength; this.index++) + { + if (cache[this.index].Update() is { } ao) + { + this.Current = ao; + return true; + } + } + + return false; + } + + public void InitializeForPooledObjects(ObjectTable ot) => this.owner = ot; + + public void Reset() => this.index = -1; + + public void Dispose() + { + if (this.owner is not { } o) + return; + + if (this.slotId == -1) + o.multiThreadedEnumerators.Return(this); + else + o.frameworkThreadEnumerators[this.slotId] = this; + } + + public bool TryReset() + { + this.Reset(); + return true; + } + } } diff --git a/Dalamud/Game/ClientState/Objects/Types/GameObject.cs b/Dalamud/Game/ClientState/Objects/Types/GameObject.cs index 292430b27..3d5b4c288 100644 --- a/Dalamud/Game/ClientState/Objects/Types/GameObject.cs +++ b/Dalamud/Game/ClientState/Objects/Types/GameObject.cs @@ -29,7 +29,7 @@ public unsafe partial class GameObject : IEquatable /// /// Gets the address of the game object in memory. /// - public IntPtr Address { get; } + public IntPtr Address { get; internal set; } /// /// Gets the Dalamud instance. diff --git a/Dalamud/Game/Framework.cs b/Dalamud/Game/Framework.cs index 9e520daab..35ac2fd10 100644 --- a/Dalamud/Game/Framework.cs +++ b/Dalamud/Game/Framework.cs @@ -26,9 +26,9 @@ namespace Dalamud.Game; internal sealed class Framework : IInternalDisposableService, IFramework { private static readonly ModuleLog Log = new("Framework"); - + private static readonly Stopwatch StatsStopwatch = new(); - + private readonly GameLifecycle lifecycle; private readonly Stopwatch updateStopwatch = new(); @@ -87,6 +87,11 @@ internal sealed class Framework : IInternalDisposableService, IFramework /// public event IFramework.OnUpdateDelegate? Update; + /// + /// Executes during FrameworkUpdate before all delegates. + /// + internal event IFramework.OnUpdateDelegate BeforeUpdate; + /// /// Gets or sets a value indicating whether the collection of stats is enabled. /// @@ -333,7 +338,7 @@ internal sealed class Framework : IInternalDisposableService, IFramework this.updateStopwatch.Reset(); StatsStopwatch.Reset(); } - + /// /// Adds a update time to the stats history. /// @@ -360,7 +365,7 @@ internal sealed class Framework : IInternalDisposableService, IFramework internal void ProfileAndInvoke(IFramework.OnUpdateDelegate? eventDelegate, IFramework frameworkInstance) { if (eventDelegate is null) return; - + var invokeList = eventDelegate.GetInvocationList(); // Individually invoke OnUpdate handlers and time them. @@ -392,6 +397,8 @@ internal sealed class Framework : IInternalDisposableService, IFramework ThreadSafety.MarkMainThread(); + this.BeforeUpdate?.InvokeSafely(this); + this.hitchDetector.Start(); try @@ -476,7 +483,7 @@ internal sealed class Framework : IInternalDisposableService, IFramework this.hitchDetector.Stop(); - original: + original: return this.updateHook.OriginalDisposeSafe(framework); } @@ -529,19 +536,19 @@ internal class FrameworkPluginScoped : IInternalDisposableService, IFramework /// public DateTime LastUpdate => this.frameworkService.LastUpdate; - + /// public DateTime LastUpdateUTC => this.frameworkService.LastUpdateUTC; /// public TimeSpan UpdateDelta => this.frameworkService.UpdateDelta; - + /// public bool IsInFrameworkUpdateThread => this.frameworkService.IsInFrameworkUpdateThread; - + /// public bool IsFrameworkUnloading => this.frameworkService.IsFrameworkUnloading; - + /// void IInternalDisposableService.DisposeService() { @@ -576,27 +583,27 @@ internal class FrameworkPluginScoped : IInternalDisposableService, IFramework /// public Task RunOnFrameworkThread(Func func) => this.frameworkService.RunOnFrameworkThread(func); - + /// public Task RunOnFrameworkThread(Action action) => this.frameworkService.RunOnFrameworkThread(action); - + /// public Task RunOnFrameworkThread(Func> func) => this.frameworkService.RunOnFrameworkThread(func); - + /// public Task RunOnFrameworkThread(Func func) => this.frameworkService.RunOnFrameworkThread(func); - + /// public Task RunOnTick(Func func, TimeSpan delay = default, int delayTicks = default, CancellationToken cancellationToken = default) => this.frameworkService.RunOnTick(func, delay, delayTicks, cancellationToken); - + /// public Task RunOnTick(Action action, TimeSpan delay = default, int delayTicks = default, CancellationToken cancellationToken = default) => this.frameworkService.RunOnTick(action, delay, delayTicks, cancellationToken); - + /// public Task RunOnTick(Func> func, TimeSpan delay = default, int delayTicks = default, CancellationToken cancellationToken = default) => this.frameworkService.RunOnTick(func, delay, delayTicks, cancellationToken); diff --git a/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.ImGui.cs b/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.ImGui.cs index d4a08ff69..6d7a47c27 100644 --- a/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.ImGui.cs +++ b/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.ImGui.cs @@ -1,5 +1,6 @@ using System.Numerics; +using Dalamud.Configuration.Internal; using Dalamud.Interface.Internal; using Dalamud.Interface.Utility; using Dalamud.Utility; @@ -20,8 +21,8 @@ internal sealed partial class ActiveNotification var opacity = Math.Clamp( (float)(this.hideEasing.IsRunning - ? (this.hideEasing.IsDone ? 0 : 1f - this.hideEasing.Value) - : (this.showEasing.IsDone ? 1 : this.showEasing.Value)), + ? (this.hideEasing.IsDone || ReducedMotions ? 0 : 1f - this.hideEasing.Value) + : (this.showEasing.IsDone || ReducedMotions ? 1 : this.showEasing.Value)), 0f, 1f); if (opacity <= 0) @@ -97,24 +98,25 @@ internal sealed partial class ActiveNotification this.lastInterestTime = DateTime.Now; this.DrawWindowBackgroundProgressBar(); - this.DrawTopBar(width, actionWindowHeight, isHovered); + this.DrawTopBar(width, actionWindowHeight, isHovered, warrantsExtension); if (!this.underlyingNotification.Minimized && !this.expandoEasing.IsRunning) { this.DrawContentAndActions(width, actionWindowHeight); } else if (this.expandoEasing.IsRunning) { + var easedValue = ReducedMotions ? 1f : (float)this.expandoEasing.Value; if (this.underlyingNotification.Minimized) - ImGui.PushStyleVar(ImGuiStyleVar.Alpha, opacity * (1f - (float)this.expandoEasing.Value)); + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, opacity * (1f - easedValue)); else - ImGui.PushStyleVar(ImGuiStyleVar.Alpha, opacity * (float)this.expandoEasing.Value); + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, opacity * easedValue); this.DrawContentAndActions(width, actionWindowHeight); ImGui.PopStyleVar(); } if (isFocused) this.DrawFocusIndicator(); - this.DrawExpiryBar(this.EffectiveExpiry, warrantsExtension); + this.DrawExpiryBar(warrantsExtension); if (ImGui.IsWindowHovered()) { @@ -184,24 +186,36 @@ internal sealed partial class ActiveNotification private void DrawWindowBackgroundProgressBar() { - var elapsed = (float)(((DateTime.Now - this.CreatedAt).TotalMilliseconds % - NotificationConstants.ProgressWaveLoopDuration) / - NotificationConstants.ProgressWaveLoopDuration); - elapsed /= NotificationConstants.ProgressWaveIdleTimeRatio; + var elapsed = 0f; + var colorElapsed = 0f; + float progress; - var colorElapsed = - elapsed < NotificationConstants.ProgressWaveLoopMaxColorTimeRatio - ? elapsed / NotificationConstants.ProgressWaveLoopMaxColorTimeRatio - : ((NotificationConstants.ProgressWaveLoopMaxColorTimeRatio * 2) - elapsed) / - NotificationConstants.ProgressWaveLoopMaxColorTimeRatio; + if (ReducedMotions) + { + progress = this.Progress; + } + else + { + progress = Math.Clamp(this.ProgressEased, 0f, 1f); - elapsed = Math.Clamp(elapsed, 0f, 1f); - colorElapsed = Math.Clamp(colorElapsed, 0f, 1f); - colorElapsed = MathF.Sin(colorElapsed * (MathF.PI / 2f)); + elapsed = + (float)(((DateTime.Now - this.CreatedAt).TotalMilliseconds % + NotificationConstants.ProgressWaveLoopDuration) / + NotificationConstants.ProgressWaveLoopDuration); + elapsed /= NotificationConstants.ProgressWaveIdleTimeRatio; - var progress = Math.Clamp(this.ProgressEased, 0f, 1f); - if (progress >= 1f) - elapsed = colorElapsed = 0f; + colorElapsed = elapsed < NotificationConstants.ProgressWaveLoopMaxColorTimeRatio + ? elapsed / NotificationConstants.ProgressWaveLoopMaxColorTimeRatio + : ((NotificationConstants.ProgressWaveLoopMaxColorTimeRatio * 2) - elapsed) / + NotificationConstants.ProgressWaveLoopMaxColorTimeRatio; + + elapsed = Math.Clamp(elapsed, 0f, 1f); + colorElapsed = Math.Clamp(colorElapsed, 0f, 1f); + colorElapsed = MathF.Sin(colorElapsed * (MathF.PI / 2f)); + + if (progress >= 1f) + elapsed = colorElapsed = 0f; + } var windowPos = ImGui.GetWindowPos(); var windowSize = ImGui.GetWindowSize(); @@ -240,7 +254,7 @@ internal sealed partial class ActiveNotification ImGui.PopClipRect(); } - private void DrawTopBar(float width, float height, bool drawActionButtons) + private void DrawTopBar(float width, float height, bool drawActionButtons, bool warrantsExtension) { var windowPos = ImGui.GetWindowPos(); var windowSize = ImGui.GetWindowSize(); @@ -249,6 +263,10 @@ internal sealed partial class ActiveNotification using (Service.Get().IconFontHandle?.Push()) { ImGui.PushClipRect(windowPos, windowPos + windowSize with { Y = height }, false); + + if (!drawActionButtons) + this.DrawExpiryPie(warrantsExtension, new(width - height, 0), new(height)); + if (this.UserDismissable) { if (this.DrawIconButton(FontAwesomeIcon.Times, rtOffset, height, drawActionButtons)) @@ -272,7 +290,7 @@ internal sealed partial class ActiveNotification } float relativeOpacity; - if (this.expandoEasing.IsRunning) + if (this.expandoEasing.IsRunning && !ReducedMotions) { relativeOpacity = this.underlyingNotification.Minimized @@ -297,36 +315,35 @@ internal sealed partial class ActiveNotification ImGui.TextUnformatted( ImGui.IsWindowHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) ? this.CreatedAt.LocAbsolute() - : this.CreatedAt.LocRelativePastLong()); + : ReducedMotions + ? this.CreatedAt.LocRelativePastLong(TimeSpan.FromSeconds(15)) + : this.CreatedAt.LocRelativePastLong(TimeSpan.FromSeconds(5))); ImGui.PopStyleColor(); ImGui.PopStyleVar(); } if (relativeOpacity < 1) { - rtOffset = new(width - NotificationConstants.ScaledWindowPadding, 0); ImGui.PushStyleVar(ImGuiStyleVar.Alpha, ImGui.GetStyle().Alpha * (1f - relativeOpacity)); - var ltOffset = new Vector2(NotificationConstants.ScaledWindowPadding); - this.DrawIcon(ltOffset, new(height - (2 * NotificationConstants.ScaledWindowPadding))); - - ltOffset.X = height; - - var agoText = this.CreatedAt.LocRelativePastShort(); + var agoText = + ReducedMotions + ? this.CreatedAt.LocRelativePastShort(TimeSpan.FromSeconds(15)) + : this.CreatedAt.LocRelativePastShort(TimeSpan.FromSeconds(5)); var agoSize = ImGui.CalcTextSize(agoText); - rtOffset.X -= agoSize.X; - ImGui.SetCursorPos(rtOffset with { Y = NotificationConstants.ScaledWindowPadding }); + ImGui.SetCursorPos(new(width - ((height + agoSize.X) / 2f), NotificationConstants.ScaledWindowPadding)); ImGui.PushStyleColor(ImGuiCol.Text, NotificationConstants.WhenTextColor); ImGui.TextUnformatted(agoText); ImGui.PopStyleColor(); - rtOffset.X -= NotificationConstants.ScaledWindowPadding; - + this.DrawIcon( + new(NotificationConstants.ScaledWindowPadding), + new(height - (2 * NotificationConstants.ScaledWindowPadding))); ImGui.PushClipRect( - windowPos + ltOffset with { Y = 0 }, - windowPos + rtOffset with { Y = height }, + windowPos + new Vector2(height, 0), + windowPos + new Vector2(width - height, height), true); - ImGui.SetCursorPos(ltOffset with { Y = NotificationConstants.ScaledWindowPadding }); + ImGui.SetCursorPos(new(height, NotificationConstants.ScaledWindowPadding)); ImGui.TextUnformatted(this.EffectiveMinimizedText); ImGui.PopClipRect(); @@ -437,12 +454,95 @@ internal sealed partial class ActiveNotification ImGui.PopTextWrapPos(); } - private void DrawExpiryBar(DateTime effectiveExpiry, bool warrantsExtension) + private void DrawExpiryPie(bool warrantsExtension, Vector2 offset, Vector2 size) { + if (!Service.Get().ReduceMotions ?? false) + return; + + // circle here; 0 means 0deg; 1 means 360deg + float fillStartCw, fillEndCw; + if (this.DismissReason is not null) + { + fillStartCw = fillEndCw = 0f; + } + else if (warrantsExtension) + { + fillStartCw = fillEndCw = 0f; + } + else if (this.EffectiveExpiry == DateTime.MaxValue) + { + if (this.ShowIndeterminateIfNoExpiry) + { + // draw + var elapsed = (float)(((DateTime.Now - this.CreatedAt).TotalMilliseconds % + NotificationConstants.IndeterminatePieLoopDuration) / + NotificationConstants.IndeterminatePieLoopDuration); + fillStartCw = elapsed; + fillEndCw = elapsed + 0.2f + (MathF.Sin(elapsed * MathF.PI) * 0.2f); + } + else + { + // do not draw + fillStartCw = fillEndCw = 0f; + } + } + else + { + fillStartCw = 1f - (float)((this.EffectiveExpiry - DateTime.Now).TotalMilliseconds / + (this.EffectiveExpiry - this.lastInterestTime).TotalMilliseconds); + fillEndCw = 1f; + } + + if (fillStartCw > fillEndCw) + (fillStartCw, fillEndCw) = (fillEndCw, fillStartCw); + + if (fillStartCw == 0 && fillEndCw == 0) + return; + + var radius = Math.Min(size.X, size.Y) / 3f; + var ifrom = fillStartCw * MathF.PI * 2; + var ito = fillEndCw * MathF.PI * 2; + + var nseg = MathF.Ceiling(2 * MathF.PI * radius); + var step = (MathF.PI * 2) / nseg; + + var center = ImGui.GetWindowPos() + offset + (size / 2); + var color = ImGui.GetColorU32(this.Type.ToColor() * new Vector4(1, 1, 1, 0.2f)); + + var prevOff = center + (radius * new Vector2(MathF.Sin(ifrom), -MathF.Cos(ifrom))); + Span verts = stackalloc Vector2[(int)MathF.Ceiling(((ito - ifrom) / step) + 3)]; + var vertPtr = 0; + verts[vertPtr++] = center; + verts[vertPtr++] = prevOff; + + var cur = ifrom + step; + for (; cur < ito; cur += step) + { + var curOff = center + (radius * new Vector2(MathF.Sin(cur), -MathF.Cos(cur))); + if (Vector2.DistanceSquared(prevOff, curOff) >= 1) + verts[vertPtr++] = prevOff = curOff; + } + + var lastOff = center + (radius * new Vector2(MathF.Sin(ito), -MathF.Cos(ito))); + if (Vector2.DistanceSquared(prevOff, lastOff) >= 1) + verts[vertPtr++] = lastOff; + unsafe + { + var dlist = ImGui.GetWindowDrawList().NativePtr; + fixed (Vector2* pvert = verts) + ImGuiNative.ImDrawList_AddConvexPolyFilled(dlist, pvert, vertPtr, color); + } + } + + private void DrawExpiryBar(bool warrantsExtension) + { + if (Service.Get().ReduceMotions ?? false) + return; + float barL, barR; if (this.DismissReason is not null) { - var v = this.hideEasing.IsDone ? 0f : 1f - (float)this.hideEasing.Value; + var v = this.hideEasing.IsDone || ReducedMotions ? 0f : 1f - (float)this.hideEasing.Value; var midpoint = (this.prevProgressL + this.prevProgressR) / 2f; var length = (this.prevProgressR - this.prevProgressL) / 2f; barL = midpoint - (length * v); @@ -455,7 +555,7 @@ internal sealed partial class ActiveNotification this.prevProgressL = barL; this.prevProgressR = barR; } - else if (effectiveExpiry == DateTime.MaxValue) + else if (this.EffectiveExpiry == DateTime.MaxValue) { if (this.ShowIndeterminateIfNoExpiry) { @@ -477,8 +577,8 @@ internal sealed partial class ActiveNotification } else { - barL = 1f - (float)((effectiveExpiry - DateTime.Now).TotalMilliseconds / - (effectiveExpiry - this.lastInterestTime).TotalMilliseconds); + barL = 1f - (float)((this.EffectiveExpiry - DateTime.Now).TotalMilliseconds / + (this.EffectiveExpiry - this.lastInterestTime).TotalMilliseconds); barR = 1f; this.prevProgressL = barL; this.prevProgressR = barR; diff --git a/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.cs b/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.cs index 3bc7c3837..5ae7de5f7 100644 --- a/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.cs +++ b/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.cs @@ -2,6 +2,7 @@ using System.Runtime.Loader; using System.Threading; using System.Threading.Tasks; +using Dalamud.Configuration.Internal; using Dalamud.Interface.Animation; using Dalamud.Interface.Animation.EasingFunctions; using Dalamud.Interface.Internal; @@ -187,16 +188,18 @@ internal sealed partial class ActiveNotification : IActiveNotification set => this.newProgress = value; } + private static bool ReducedMotions => Service.Get().ReduceMotions ?? false; + /// Gets the eased progress. private float ProgressEased { get { var underlyingProgress = this.underlyingNotification.Progress; - if (Math.Abs(underlyingProgress - this.progressBefore) < 0.000001f || this.progressEasing.IsDone) + if (Math.Abs(underlyingProgress - this.progressBefore) < 0.000001f || this.progressEasing.IsDone || ReducedMotions) return underlyingProgress; - var state = Math.Clamp((float)this.progressEasing.Value, 0f, 1f); + var state = ReducedMotions ? 1f : Math.Clamp((float)this.progressEasing.Value, 0f, 1f); return this.progressBefore + (state * (underlyingProgress - this.progressBefore)); } } diff --git a/Dalamud/Interface/ImGuiNotification/Internal/NotificationConstants.cs b/Dalamud/Interface/ImGuiNotification/Internal/NotificationConstants.cs index de212160c..18bb57118 100644 --- a/Dalamud/Interface/ImGuiNotification/Internal/NotificationConstants.cs +++ b/Dalamud/Interface/ImGuiNotification/Internal/NotificationConstants.cs @@ -41,6 +41,10 @@ internal static class NotificationConstants /// The duration of indeterminate progress bar loop in milliseconds. public const float IndeterminateProgressbarLoopDuration = 2000f; + /// The duration of indeterminate pie loop in milliseconds. + /// Note that this value is applicable when reduced motion configuration is on. + public const float IndeterminatePieLoopDuration = 8000f; + /// The duration of the progress wave animation in milliseconds. public const float ProgressWaveLoopDuration = 2000f; diff --git a/Dalamud/Interface/ImGuiNotification/NotificationUtilities.cs b/Dalamud/Interface/ImGuiNotification/NotificationUtilities.cs index 631263f95..04f275e43 100644 --- a/Dalamud/Interface/ImGuiNotification/NotificationUtilities.cs +++ b/Dalamud/Interface/ImGuiNotification/NotificationUtilities.cs @@ -129,7 +129,7 @@ public static class NotificationUtilities plugin, plugin.Manifest, plugin.IsThirdParty, - out var texture) || texture is null) + out var texture, out _) || texture is null) { texture = dam.GetDalamudTextureWrap(DalamudAsset.DefaultIcon); } diff --git a/Dalamud/Interface/Internal/Windows/PluginImageCache.cs b/Dalamud/Interface/Internal/Windows/PluginImageCache.cs index 97744b1a7..634999143 100644 --- a/Dalamud/Interface/Internal/Windows/PluginImageCache.cs +++ b/Dalamud/Interface/Internal/Windows/PluginImageCache.cs @@ -54,8 +54,8 @@ internal class PluginImageCache : IInternalDisposableService private readonly CancellationTokenSource cancelToken = new(); private readonly Task downloadTask; private readonly Task loadTask; - - private readonly ConcurrentDictionary pluginIconMap = new(); + + private readonly ConcurrentDictionary pluginIconMap = new(); private readonly ConcurrentDictionary pluginImagesMap = new(); private readonly DalamudAssetManager dalamudAssetManager; @@ -153,7 +153,7 @@ internal class PluginImageCache : IInternalDisposableService foreach (var icon in this.pluginIconMap.Values) { - icon?.Dispose(); + icon?.Texture.Dispose(); } foreach (var images in this.pluginImagesMap.Values) @@ -185,10 +185,12 @@ internal class PluginImageCache : IInternalDisposableService /// The plugin manifest. /// If the plugin was third party sourced. /// Cached image textures, or an empty array. + /// The time the icon was successfully downloaded. /// True if an entry exists, may be null if currently downloading. - public bool TryGetIcon(LocalPlugin? plugin, IPluginManifest manifest, bool isThirdParty, out IDalamudTextureWrap? iconTexture) + public bool TryGetIcon(LocalPlugin? plugin, IPluginManifest manifest, bool isThirdParty, out IDalamudTextureWrap? iconTexture, out DateTime? loadedSince) { iconTexture = null; + loadedSince = null; if (manifest == null || manifest.InternalName == null) { @@ -198,7 +200,13 @@ internal class PluginImageCache : IInternalDisposableService if (!this.pluginIconMap.TryAdd(manifest.InternalName, null)) { - iconTexture = this.pluginIconMap[manifest.InternalName]; + var loaded = this.pluginIconMap[manifest.InternalName]; + if (loaded != null) + { + iconTexture = loaded.Texture; + loadedSince = loaded.LoadedSince; + } + return true; } @@ -207,8 +215,9 @@ internal class PluginImageCache : IInternalDisposableService { try { - this.pluginIconMap[manifest.InternalName] = - await this.DownloadPluginIconAsync(plugin, manifest, isThirdParty, requestedFrame); + var texture = await this.DownloadPluginIconAsync(plugin, manifest, isThirdParty, requestedFrame); + if (texture != null) + this.pluginIconMap[manifest.InternalName] = new LoadedIcon(texture, DateTime.Now); } catch (Exception ex) { @@ -690,4 +699,11 @@ internal class PluginImageCache : IInternalDisposableService return output; } + + /// + /// Record for a loaded icon. + /// + /// The texture of the icon. + /// The time the icon was loaded at. + private record LoadedIcon(IDalamudTextureWrap Texture, DateTime LoadedSince); } diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs index 210290f17..ea49ef3ba 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs @@ -1158,10 +1158,13 @@ internal class PluginInstallerWindow : Window, IDisposable // Go through all AVAILABLE manifests, associate them with a NON-DEV local plugin, if one is available, and remove it from the pile foreach (var availableManifest in this.categoryManager.GetCurrentCategoryContent(filteredAvailableManifests).Cast()) { - var plugin = this.pluginListInstalled.FirstOrDefault(plugin => plugin.Manifest.InternalName == availableManifest.InternalName && plugin.Manifest.RepoUrl == availableManifest.RepoUrl); + var plugin = this.pluginListInstalled + .FirstOrDefault(plugin => plugin.Manifest.InternalName == availableManifest.InternalName && + plugin.Manifest.RepoUrl == availableManifest.RepoUrl && + !plugin.IsDev); // We "consumed" this plugin from the pile and remove it. - if (plugin != null && !plugin.IsDev) + if (plugin != null) { installedPlugins.Remove(plugin); proxies.Add(new PluginInstallerAvailablePluginProxy(null, plugin)); @@ -1808,26 +1811,35 @@ internal class PluginInstallerWindow : Window, IDisposable var iconSize = ImGuiHelpers.ScaledVector2(64, 64); var cursorBeforeImage = ImGui.GetCursorPos(); var rectOffset = ImGui.GetWindowContentRegionMin() + ImGui.GetWindowPos(); + + var overlayAlpha = 1.0f; if (ImGui.IsRectVisible(rectOffset + cursorBeforeImage, rectOffset + cursorBeforeImage + iconSize)) { var iconTex = this.imageCache.DefaultIcon; - var hasIcon = this.imageCache.TryGetIcon(plugin, manifest, isThirdParty, out var cachedIconTex); + var hasIcon = this.imageCache.TryGetIcon(plugin, manifest, isThirdParty, out var cachedIconTex, out var loadedSince); if (hasIcon && cachedIconTex != null) { iconTex = cachedIconTex; } + + const float fadeTime = 0.3f; + var iconAlpha = 1f; - if (pluginDisabled || installableOutdated) + if (loadedSince.HasValue) { - ImGui.PushStyleVar(ImGuiStyleVar.Alpha, 0.4f); + float EaseOutCubic(float t) => 1 - MathF.Pow(1 - t, 3); + + var secondsSinceLoad = (float)DateTime.Now.Subtract(loadedSince.Value).TotalSeconds; + var fadeTo = pluginDisabled || installableOutdated ? 0.4f : 1f; + + float Interp(float to) => Math.Clamp(EaseOutCubic(Math.Min(secondsSinceLoad, fadeTime) / fadeTime) * to, 0, 1); + iconAlpha = Interp(fadeTo); + overlayAlpha = Interp(1f); } + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, iconAlpha); ImGui.Image(iconTex.ImGuiHandle, iconSize); - - if (pluginDisabled || installableOutdated) - { - ImGui.PopStyleVar(); - } + ImGui.PopStyleVar(); ImGui.SameLine(); ImGui.SetCursorPos(cursorBeforeImage); @@ -1835,6 +1847,7 @@ internal class PluginInstallerWindow : Window, IDisposable var isLoaded = plugin is { IsLoaded: true }; + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, overlayAlpha); if (updateAvailable) ImGui.Image(this.imageCache.UpdateIcon.ImGuiHandle, iconSize); else if ((trouble && !pluginDisabled) || isOrphan) @@ -1853,6 +1866,8 @@ internal class PluginInstallerWindow : Window, IDisposable ImGui.Image(this.imageCache.InstalledIcon.ImGuiHandle, iconSize); else ImGui.Dummy(iconSize); + ImGui.PopStyleVar(); + ImGui.SameLine(); ImGuiHelpers.ScaledDummy(5); @@ -2016,7 +2031,7 @@ internal class PluginInstallerWindow : Window, IDisposable if (log is PluginChangelogEntry pluginLog) { icon = this.imageCache.DefaultIcon; - var hasIcon = this.imageCache.TryGetIcon(pluginLog.Plugin, pluginLog.Plugin.Manifest, pluginLog.Plugin.IsThirdParty, out var cachedIconTex); + var hasIcon = this.imageCache.TryGetIcon(pluginLog.Plugin, pluginLog.Plugin.Manifest, pluginLog.Plugin.IsThirdParty, out var cachedIconTex, out _); if (hasIcon && cachedIconTex != null) { icon = cachedIconTex; diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs index 857002771..9f3196928 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs @@ -437,7 +437,7 @@ internal class ProfileManagerWidget if (pmPlugin != null) { var cursorBeforeIcon = ImGui.GetCursorPos(); - pic.TryGetIcon(pmPlugin, pmPlugin.Manifest, pmPlugin.IsThirdParty, out var icon); + pic.TryGetIcon(pmPlugin, pmPlugin.Manifest, pmPlugin.IsThirdParty, out var icon, out _); icon ??= pic.DefaultIcon; ImGui.Image(icon.ImGuiHandle, new Vector2(pluginLineHeight)); diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/ContextMenuAgingStep.cs b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/ContextMenuAgingStep.cs index 579f8357b..cfe06fca9 100644 --- a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/ContextMenuAgingStep.cs +++ b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/ContextMenuAgingStep.cs @@ -125,7 +125,7 @@ internal class ContextMenuAgingStep : IAgingStep private void OnMenuOpened(MenuOpenedArgs args) { - LogMenuOpened(args); + this.LogMenuOpened(args); switch (this.currentSubStep) { diff --git a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs index 5ccace850..c7fcdc58d 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs @@ -130,6 +130,12 @@ public class SettingsTabLook : SettingsTab Loc.Localize("DalamudSettingInstallerOpenDefaultHint", "This will allow you to open the Plugin Installer to the \"Installed Plugins\" tab by default, instead of the \"Available Plugins\" tab."), c => c.PluginInstallerOpen == PluginInstallerWindow.PluginInstallerOpenKind.InstalledPlugins, (v, c) => c.PluginInstallerOpen = v ? PluginInstallerWindow.PluginInstallerOpenKind.InstalledPlugins : PluginInstallerWindow.PluginInstallerOpenKind.AllPlugins), + + new SettingsEntry( + Loc.Localize("DalamudSettingReducedMotion", "Reduce motions"), + Loc.Localize("DalamudSettingReducedMotion", "This will suppress certain animations from Dalamud, such as the notification popup."), + c => c.ReduceMotions ?? false, + (v, c) => c.ReduceMotions = v), }; public override string Title => Loc.Localize("DalamudSettingsVisual", "Look & Feel"); diff --git a/Dalamud/Interface/Internal/Windows/StyleEditor/StyleEditorWindow.cs b/Dalamud/Interface/Internal/Windows/StyleEditor/StyleEditorWindow.cs index 9ee4123cd..fb556ba45 100644 --- a/Dalamud/Interface/Internal/Windows/StyleEditor/StyleEditorWindow.cs +++ b/Dalamud/Interface/Internal/Windows/StyleEditor/StyleEditorWindow.cs @@ -82,6 +82,7 @@ public class StyleEditorWindow : Window var workStyle = config.SavedStyles[this.currentSel]; workStyle.BuiltInColors ??= StyleModelV1.DalamudStandard.BuiltInColors; + var isBuiltinStyle = this.currentSel < 2; var appliedThisFrame = false; var styleAry = config.SavedStyles.Select(x => x.Name).ToArray(); @@ -111,6 +112,9 @@ public class StyleEditorWindow : Window ImGui.SameLine(); + if (isBuiltinStyle) + ImGui.BeginDisabled(); + if (ImGuiComponents.IconButton(FontAwesomeIcon.Trash) && this.currentSel != 0) { this.currentSel--; @@ -155,6 +159,9 @@ public class StyleEditorWindow : Window if (ImGui.IsItemHovered()) ImGui.SetTooltip(Loc.Localize("StyleEditorCopy", "Copy style to clipboard for sharing")); + + if (isBuiltinStyle) + ImGui.EndDisabled(); ImGui.SameLine(); @@ -196,7 +203,7 @@ public class StyleEditorWindow : Window ImGui.PushItemWidth(ImGui.GetWindowWidth() * 0.50f); - if (this.currentSel < 2) + if (isBuiltinStyle) { ImGui.TextColored(ImGuiColors.DalamudRed, Loc.Localize("StyleEditorNotAllowed", "You cannot edit built-in styles. Please add a new style first.")); } diff --git a/Dalamud/IoC/Internal/ServiceContainer.cs b/Dalamud/IoC/Internal/ServiceContainer.cs index 5b141979e..9adf37f85 100644 --- a/Dalamud/IoC/Internal/ServiceContainer.cs +++ b/Dalamud/IoC/Internal/ServiceContainer.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Reflection; +using System.Runtime.CompilerServices; using System.Runtime.Serialization; using System.Threading.Tasks; @@ -132,7 +133,7 @@ internal class ServiceContainer : IServiceProvider, IServiceType return null; } - var instance = FormatterServices.GetUninitializedObject(objectType); + var instance = RuntimeHelpers.GetUninitializedObject(objectType); if (!await this.InjectProperties(instance, scopedObjects, scope)) { diff --git a/Dalamud/Logging/Internal/TaskTracker.cs b/Dalamud/Logging/Internal/TaskTracker.cs index 9ecabe6c7..ae3dae5e9 100644 --- a/Dalamud/Logging/Internal/TaskTracker.cs +++ b/Dalamud/Logging/Internal/TaskTracker.cs @@ -23,7 +23,8 @@ internal class TaskTracker : IInternalDisposableService [ServiceManager.ServiceDependency] private readonly Framework framework = Service.Get(); - private MonoMod.RuntimeDetour.Hook? scheduleAndStartHook; + // NET8 CHORE + // private MonoMod.RuntimeDetour.Hook? scheduleAndStartHook; private bool enabled = false; [ServiceManager.ServiceConstructor] @@ -121,7 +122,8 @@ internal class TaskTracker : IInternalDisposableService /// void IInternalDisposableService.DisposeService() { - this.scheduleAndStartHook?.Dispose(); + // NET8 CHORE + // this.scheduleAndStartHook?.Dispose(); this.framework.Update -= this.FrameworkOnUpdate; } @@ -170,7 +172,8 @@ internal class TaskTracker : IInternalDisposableService return; } - this.scheduleAndStartHook = new MonoMod.RuntimeDetour.Hook(targetMethod, patchMethod); + // NET8 CHORE + // this.scheduleAndStartHook = new MonoMod.RuntimeDetour.Hook(targetMethod, patchMethod); Log.Information("AddToActiveTasks Hooked!"); } diff --git a/Dalamud/Memory/Exceptions/MemoryAllocationException.cs b/Dalamud/Memory/Exceptions/MemoryAllocationException.cs index 61f124bad..efa875def 100644 --- a/Dalamud/Memory/Exceptions/MemoryAllocationException.cs +++ b/Dalamud/Memory/Exceptions/MemoryAllocationException.cs @@ -33,14 +33,4 @@ public class MemoryAllocationException : MemoryException : base(message, innerException) { } - - /// - /// Initializes a new instance of the class. - /// - /// The object that holds the serialized data about the exception being thrown. - /// The object that contains contextual information about the source or destination. - protected MemoryAllocationException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } } diff --git a/Dalamud/Memory/Exceptions/MemoryException.cs b/Dalamud/Memory/Exceptions/MemoryException.cs index 6cb1b887c..117a13c6b 100644 --- a/Dalamud/Memory/Exceptions/MemoryException.cs +++ b/Dalamud/Memory/Exceptions/MemoryException.cs @@ -33,14 +33,4 @@ public abstract class MemoryException : Exception : base(message, innerException) { } - - /// - /// Initializes a new instance of the class. - /// - /// The object that holds the serialized data about the exception being thrown. - /// The object that contains contextual information about the source or destination. - protected MemoryException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } } diff --git a/Dalamud/Memory/Exceptions/MemoryPermissionException.cs b/Dalamud/Memory/Exceptions/MemoryPermissionException.cs index b4dddfc5f..abc749740 100644 --- a/Dalamud/Memory/Exceptions/MemoryPermissionException.cs +++ b/Dalamud/Memory/Exceptions/MemoryPermissionException.cs @@ -33,14 +33,4 @@ public class MemoryPermissionException : MemoryException : base(message, innerException) { } - - /// - /// Initializes a new instance of the class. - /// - /// The object that holds the serialized data about the exception being thrown. - /// The object that contains contextual information about the source or destination. - protected MemoryPermissionException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } } diff --git a/Dalamud/Memory/Exceptions/MemoryReadException.cs b/Dalamud/Memory/Exceptions/MemoryReadException.cs index ee02c5473..f0b79075d 100644 --- a/Dalamud/Memory/Exceptions/MemoryReadException.cs +++ b/Dalamud/Memory/Exceptions/MemoryReadException.cs @@ -33,14 +33,4 @@ public class MemoryReadException : MemoryException : base(message, innerException) { } - - /// - /// Initializes a new instance of the class. - /// - /// The object that holds the serialized data about the exception being thrown. - /// The object that contains contextual information about the source or destination. - protected MemoryReadException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } } diff --git a/Dalamud/Memory/Exceptions/MemoryWriteException.cs b/Dalamud/Memory/Exceptions/MemoryWriteException.cs index edbf06fdc..87011edd3 100644 --- a/Dalamud/Memory/Exceptions/MemoryWriteException.cs +++ b/Dalamud/Memory/Exceptions/MemoryWriteException.cs @@ -33,14 +33,4 @@ public class MemoryWriteException : MemoryException : base(message, innerException) { } - - /// - /// Initializes a new instance of the class. - /// - /// The object that holds the serialized data about the exception being thrown. - /// The object that contains contextual information about the source or destination. - protected MemoryWriteException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } } diff --git a/Dalamud/NativeFunctions.cs b/Dalamud/NativeFunctions.cs index 92dfe5dd7..14a39a6be 100644 --- a/Dalamud/NativeFunctions.cs +++ b/Dalamud/NativeFunctions.cs @@ -826,6 +826,27 @@ internal static partial class NativeFunctions /// public uint Timeout; } + + /// + /// Parameters for use with SystemParametersInfo. + /// + public enum AccessibilityParameter + { +#pragma warning disable SA1602 + SPI_GETCLIENTAREAANIMATION = 0x1042, +#pragma warning restore SA1602 + } + + /// + /// Retrieves or sets the value of one of the system-wide parameters. This function can also update the user profile while setting a parameter. + /// + /// The system-wide parameter to be retrieved or set. + /// A parameter whose usage and format depends on the system parameter being queried or set. + /// A parameter whose usage and format depends on the system parameter being queried or set. If not otherwise indicated, you must specify zero for this parameter. + /// If a system parameter is being set, specifies whether the user profile is to be updated, and if so, whether the WM_SETTINGCHANGE message is to be broadcast to all top-level windows to notify them of the change. + /// If the function succeeds, the return value is a nonzero value. + [DllImport("user32.dll", SetLastError = true)] + public static extern bool SystemParametersInfo(uint uiAction, uint uiParam, ref int pvParam, uint fWinIni); } /// diff --git a/Dalamud/Plugin/Internal/PluginManager.cs b/Dalamud/Plugin/Internal/PluginManager.cs index b815ac036..792294851 100644 --- a/Dalamud/Plugin/Internal/PluginManager.cs +++ b/Dalamud/Plugin/Internal/PluginManager.cs @@ -145,7 +145,8 @@ internal partial class PluginManager : IInternalDisposableService this.configuration.PluginTestingOptIns ??= new(); this.MainRepo = PluginRepository.CreateMainRepo(this.happyHttpClient); - this.ApplyPatches(); + // NET8 CHORE + // this.ApplyPatches(); registerStartupBlocker( Task.Run(this.LoadAndStartLoadSyncPlugins), @@ -422,8 +423,9 @@ internal partial class PluginManager : IInternalDisposableService } } - this.assemblyLocationMonoHook?.Dispose(); - this.assemblyCodeBaseMonoHook?.Dispose(); + // NET8 CHORE + // this.assemblyLocationMonoHook?.Dispose(); + // this.assemblyCodeBaseMonoHook?.Dispose(); } /// @@ -842,7 +844,8 @@ internal partial class PluginManager : IInternalDisposableService this.installedPluginsList.Remove(plugin); } - PluginLocations.Remove(plugin.AssemblyName?.FullName ?? string.Empty, out _); + // NET8 CHORE + // PluginLocations.Remove(plugin.AssemblyName?.FullName ?? string.Empty, out _); this.NotifyinstalledPluginsListChanged(); this.NotifyAvailablePluginsChanged(); @@ -1583,7 +1586,8 @@ internal partial class PluginManager : IInternalDisposableService } catch (InvalidPluginException) { - PluginLocations.Remove(plugin.AssemblyName?.FullName ?? string.Empty, out _); + // NET8 CHORE + // PluginLocations.Remove(plugin.AssemblyName?.FullName ?? string.Empty, out _); throw; } catch (BannedPluginException) @@ -1629,7 +1633,8 @@ internal partial class PluginManager : IInternalDisposableService } else { - PluginLocations.Remove(plugin.AssemblyName?.FullName ?? string.Empty, out _); + // NET8 CHORE + // PluginLocations.Remove(plugin.AssemblyName?.FullName ?? string.Empty, out _); throw; } } @@ -1756,6 +1761,8 @@ internal partial class PluginManager : IInternalDisposableService } } +// NET8 CHORE +/* /// /// Class responsible for loading and unloading plugins. /// This contains the assembly patching functionality to resolve assembly locations. @@ -1863,3 +1870,4 @@ internal partial class PluginManager this.assemblyCodeBaseMonoHook = new MonoMod.RuntimeDetour.Hook(codebaseTarget, codebasePatch); } } +*/ diff --git a/Dalamud/Plugin/Internal/Types/LocalPlugin.cs b/Dalamud/Plugin/Internal/Types/LocalPlugin.cs index 911bc436d..0c8777cfe 100644 --- a/Dalamud/Plugin/Internal/Types/LocalPlugin.cs +++ b/Dalamud/Plugin/Internal/Types/LocalPlugin.cs @@ -404,7 +404,8 @@ internal class LocalPlugin : IDisposable } // Update the location for the Location and CodeBase patches - PluginManager.PluginLocations[this.pluginType.Assembly.FullName] = new PluginPatchData(this.DllFile); + // NET8 CHORE + // PluginManager.PluginLocations[this.pluginType.Assembly.FullName] = new PluginPatchData(this.DllFile); this.DalamudInterface = new DalamudPluginInterface(this, reason); diff --git a/Dalamud/Plugin/Services/IObjectTable.cs b/Dalamud/Plugin/Services/IObjectTable.cs index d029045fa..e0f671b3c 100644 --- a/Dalamud/Plugin/Services/IObjectTable.cs +++ b/Dalamud/Plugin/Services/IObjectTable.cs @@ -1,24 +1,27 @@ using System.Collections.Generic; using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Utility; namespace Dalamud.Plugin.Services; /// /// This collection represents the currently spawned FFXIV game objects. /// +[Api10ToDo( + "Make it an IEnumerable instead. Skipping null objects make IReadOnlyCollection.Count yield incorrect values.")] public interface IObjectTable : IReadOnlyCollection { /// /// Gets the address of the object table. /// public nint Address { get; } - + /// /// Gets the length of the object table. /// public int Length { get; } - + /// /// Get an object at the specified spawn index. /// @@ -32,14 +35,14 @@ public interface IObjectTable : IReadOnlyCollection /// Object ID to find. /// A game object or null. public GameObject? SearchById(ulong objectId); - + /// /// Gets the address of the game object at the specified index of the object table. /// /// The index of the object. /// The memory address of the object. public nint GetObjectAddress(int index); - + /// /// Create a reference to an FFXIV game object. /// diff --git a/Dalamud/Utility/DateTimeSpanExtensions.cs b/Dalamud/Utility/DateTimeSpanExtensions.cs index 8422a4a26..f2dcd3d55 100644 --- a/Dalamud/Utility/DateTimeSpanExtensions.cs +++ b/Dalamud/Utility/DateTimeSpanExtensions.cs @@ -44,8 +44,9 @@ public static class DateTimeSpanExtensions /// Formats an instance of as a localized relative time. /// When. + /// The alignment unit of time span. /// The formatted string. - public static string LocRelativePastLong(this DateTime when) + public static string LocRelativePastLong(this DateTime when, TimeSpan floorBy) { var loc = Loc.Localize( "DateTimeSpanExtensions.RelativeFormatStringsLong", @@ -55,13 +56,17 @@ public static class DateTimeSpanExtensions if (relativeFormatStringLong?.FormatStringLoc != loc) relativeFormatStringLong ??= new(loc); - return relativeFormatStringLong.Format(DateTime.Now - when); + return + floorBy == default + ? relativeFormatStringLong.Format(DateTime.Now - when) + : relativeFormatStringLong.Format(Math.Floor((DateTime.Now - when) / floorBy) * floorBy); } /// Formats an instance of as a localized relative time. /// When. + /// The alignment unit of time span. /// The formatted string. - public static string LocRelativePastShort(this DateTime when) + public static string LocRelativePastShort(this DateTime when, TimeSpan floorBy) { var loc = Loc.Localize( "DateTimeSpanExtensions.RelativeFormatStringsShort", @@ -71,9 +76,22 @@ public static class DateTimeSpanExtensions if (relativeFormatStringShort?.FormatStringLoc != loc) relativeFormatStringShort = new(loc); - return relativeFormatStringShort.Format(DateTime.Now - when); + return + floorBy == default + ? relativeFormatStringShort.Format(DateTime.Now - when) + : relativeFormatStringShort.Format(Math.Floor((DateTime.Now - when) / floorBy) * floorBy); } + /// Formats an instance of as a localized relative time. + /// When. + /// The formatted string. + public static string LocRelativePastLong(this DateTime when) => when.LocRelativePastLong(TimeSpan.FromSeconds(1)); + + /// Formats an instance of as a localized relative time. + /// When. + /// The formatted string. + public static string LocRelativePastShort(this DateTime when) => when.LocRelativePastShort(TimeSpan.FromSeconds(1)); + private sealed class ParsedRelativeFormatStrings { private readonly List<(float MinSeconds, string FormatString)> formatStrings = new(); diff --git a/Dalamud/Utility/RollingList.cs b/Dalamud/Utility/RollingList.cs index 9ca012be4..0f1553bf9 100644 --- a/Dalamud/Utility/RollingList.cs +++ b/Dalamud/Utility/RollingList.cs @@ -93,8 +93,9 @@ namespace Dalamud.Utility this.firstIndex = 0; } } - else // value < this._size + else { + // value < this._size ThrowHelper.ThrowArgumentOutOfRangeExceptionIfLessThan(nameof(value), value, 0); if (value < this.Count) { @@ -156,14 +157,14 @@ namespace Dalamud.Utility } /// Add items to this . - /// Items to add. - public void AddRange(IEnumerable items) + /// Items to add. + public void AddRange(IEnumerable range) { if (this.size == 0) return; - foreach (var item in items) this.Add(item); + foreach (var item in range) this.Add(item); } - /// Removes all elements from the + /// Removes all elements from the . public void Clear() { this.items.Clear(); diff --git a/Dalamud/Utility/Signatures/SignatureHelper.cs b/Dalamud/Utility/Signatures/SignatureHelper.cs index 51f59bba2..e34d8ef69 100755 --- a/Dalamud/Utility/Signatures/SignatureHelper.cs +++ b/Dalamud/Utility/Signatures/SignatureHelper.cs @@ -90,7 +90,7 @@ internal static class SignatureHelper switch (sig.UseFlags) { - case SignatureUseFlags.Auto when actualType == typeof(IntPtr) || actualType.IsPointer || actualType.IsAssignableTo(typeof(Delegate)): + case SignatureUseFlags.Auto when actualType == typeof(IntPtr) || actualType.IsFunctionPointer || actualType.IsUnmanagedFunctionPointer || actualType.IsPointer || actualType.IsAssignableTo(typeof(Delegate)): case SignatureUseFlags.Pointer: { if (actualType.IsAssignableTo(typeof(Delegate))) diff --git a/global.json b/global.json index 133f31ec2..7e8286fbe 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "7.0.0", - "rollForward": "latestMajor", + "version": "8.0.0", + "rollForward": "latestMinor", "allowPrerelease": true } } diff --git a/lib/FFXIVClientStructs b/lib/FFXIVClientStructs index ac2ced26f..2c885a35e 160000 --- a/lib/FFXIVClientStructs +++ b/lib/FFXIVClientStructs @@ -1 +1 @@ -Subproject commit ac2ced26fc98153c65f5b8f0eaf0f464258ff683 +Subproject commit 2c885a35e0edf8ab7a335e3296f06642837afbec diff --git a/targets/Dalamud.Plugin.targets b/targets/Dalamud.Plugin.targets index 37c0940d7..513180854 100644 --- a/targets/Dalamud.Plugin.targets +++ b/targets/Dalamud.Plugin.targets @@ -1,7 +1,7 @@ - net7.0-windows + net8.0-windows x64 enable latest