Merge pull request #1731 from goatcorp/net8

.NET 8
This commit is contained in:
goat 2024-03-19 22:15:35 +01:00 committed by GitHub
commit 639cb1ac33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1081 additions and 200 deletions

View file

@ -16,6 +16,9 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Setup MSBuild - name: Setup MSBuild
uses: microsoft/setup-msbuild@v1.0.2 uses: microsoft/setup-msbuild@v1.0.2
- uses: actions/setup-dotnet@v3
with:
dotnet-version: '8.0.100'
- name: Define VERSION - name: Define VERSION
run: | run: |
$env:COMMIT = $env:GITHUB_SHA.Substring(0, 7) $env:COMMIT = $env:GITHUB_SHA.Substring(0, 7)

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>

View file

@ -23,7 +23,6 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
/// Initializes a new instance of the <see cref="GameVersion"/> class. /// Initializes a new instance of the <see cref="GameVersion"/> class.
/// </summary> /// </summary>
/// <param name="version">Version string to parse.</param> /// <param name="version">Version string to parse.</param>
[JsonConstructor]
public GameVersion(string version) public GameVersion(string version)
{ {
var ver = Parse(version); var ver = Parse(version);
@ -42,20 +41,9 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
/// <param name="day">The day.</param> /// <param name="day">The day.</param>
/// <param name="major">The major version.</param> /// <param name="major">The major version.</param>
/// <param name="minor">The minor version.</param> /// <param name="minor">The minor version.</param>
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) if ((this.Minor = minor) < 0)
throw new ArgumentOutOfRangeException(nameof(minor)); throw new ArgumentOutOfRangeException(nameof(minor));
} }
@ -67,17 +55,8 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
/// <param name="month">The month.</param> /// <param name="month">The month.</param>
/// <param name="day">The day.</param> /// <param name="day">The day.</param>
/// <param name="major">The major version.</param> /// <param name="major">The major version.</param>
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) if ((this.Major = major) < 0)
throw new ArgumentOutOfRangeException(nameof(major)); throw new ArgumentOutOfRangeException(nameof(major));
} }
@ -88,14 +67,8 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
/// <param name="year">The year.</param> /// <param name="year">The year.</param>
/// <param name="month">The month.</param> /// <param name="month">The month.</param>
/// <param name="day">The day.</param> /// <param name="day">The day.</param>
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) if ((this.Day = day) < 0)
throw new ArgumentOutOfRangeException(nameof(day)); throw new ArgumentOutOfRangeException(nameof(day));
} }
@ -105,11 +78,8 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
/// </summary> /// </summary>
/// <param name="year">The year.</param> /// <param name="year">The year.</param>
/// <param name="month">The month.</param> /// <param name="month">The month.</param>
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) if ((this.Month = month) < 0)
throw new ArgumentOutOfRangeException(nameof(month)); throw new ArgumentOutOfRangeException(nameof(month));
} }
@ -139,26 +109,31 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
/// <summary> /// <summary>
/// Gets the year component. /// Gets the year component.
/// </summary> /// </summary>
[JsonRequired]
public int Year { get; } = -1; public int Year { get; } = -1;
/// <summary> /// <summary>
/// Gets the month component. /// Gets the month component.
/// </summary> /// </summary>
[JsonRequired]
public int Month { get; } = -1; public int Month { get; } = -1;
/// <summary> /// <summary>
/// Gets the day component. /// Gets the day component.
/// </summary> /// </summary>
[JsonRequired]
public int Day { get; } = -1; public int Day { get; } = -1;
/// <summary> /// <summary>
/// Gets the major version component. /// Gets the major version component.
/// </summary> /// </summary>
[JsonRequired]
public int Major { get; } = -1; public int Major { get; } = -1;
/// <summary> /// <summary>
/// Gets the minor version component. /// Gets the minor version component.
/// </summary> /// </summary>
[JsonRequired]
public int Minor { get; } = -1; public int Minor { get; } = -1;
public static implicit operator GameVersion(string ver) public static implicit operator GameVersion(string ver)
@ -183,17 +158,13 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
public static bool operator <(GameVersion v1, GameVersion v2) public static bool operator <(GameVersion v1, GameVersion v2)
{ {
if (v1 is null) ArgumentNullException.ThrowIfNull(v1);
throw new ArgumentNullException(nameof(v1));
return v1.CompareTo(v2) < 0; return v1.CompareTo(v2) < 0;
} }
public static bool operator <=(GameVersion v1, GameVersion v2) public static bool operator <=(GameVersion v1, GameVersion v2)
{ {
if (v1 is null) ArgumentNullException.ThrowIfNull(v1);
throw new ArgumentNullException(nameof(v1));
return v1.CompareTo(v2) <= 0; return v1.CompareTo(v2) <= 0;
} }
@ -209,8 +180,7 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
public static GameVersion operator +(GameVersion v1, TimeSpan v2) public static GameVersion operator +(GameVersion v1, TimeSpan v2)
{ {
if (v1 == null) ArgumentNullException.ThrowIfNull(v1);
throw new ArgumentNullException(nameof(v1));
if (v1.Year == -1 || v1.Month == -1 || v1.Day == -1) if (v1.Year == -1 || v1.Month == -1 || v1.Day == -1)
return v1; return v1;
@ -222,8 +192,7 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
public static GameVersion operator -(GameVersion v1, TimeSpan v2) public static GameVersion operator -(GameVersion v1, TimeSpan v2)
{ {
if (v1 == null) ArgumentNullException.ThrowIfNull(v1);
throw new ArgumentNullException(nameof(v1));
if (v1.Year == -1 || v1.Month == -1 || v1.Day == -1) if (v1.Year == -1 || v1.Month == -1 || v1.Day == -1)
return v1; return v1;
@ -240,14 +209,14 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
/// <returns>GameVersion object.</returns> /// <returns>GameVersion object.</returns>
public static GameVersion Parse(string input) public static GameVersion Parse(string input)
{ {
if (input == null) ArgumentNullException.ThrowIfNull(input);
throw new ArgumentNullException(nameof(input));
if (input.ToLower(CultureInfo.InvariantCulture) == "any") if (input.ToLower(CultureInfo.InvariantCulture) == "any")
return new GameVersion(); return Any;
var parts = input.Split('.'); var parts = input.Split('.');
var tplParts = parts.Select(p => var tplParts = parts.Select(
p =>
{ {
var result = int.TryParse(p, out var value); var result = int.TryParse(p, out var value);
return (result, value); return (result, value);
@ -259,18 +228,15 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
var intParts = tplParts.Select(t => t.value).ToArray(); var intParts = tplParts.Select(t => t.value).ToArray();
var len = intParts.Length; var len = intParts.Length;
if (len == 1) return len switch
return new GameVersion(intParts[0]); {
else if (len == 2) 1 => new GameVersion(intParts[0]),
return new GameVersion(intParts[0], intParts[1]); 2 => new GameVersion(intParts[0], intParts[1]),
else if (len == 3) 3 => new GameVersion(intParts[0], intParts[1], intParts[2]),
return new GameVersion(intParts[0], intParts[1], intParts[2]); 4 => new GameVersion(intParts[0], intParts[1], intParts[2], intParts[3]),
else if (len == 4) 5 => new GameVersion(intParts[0], intParts[1], intParts[2], intParts[3], intParts[4]),
return new GameVersion(intParts[0], intParts[1], intParts[2], intParts[3]); _ => throw new ArgumentException("Too many parts"),
else if (len == 5) };
return new GameVersion(intParts[0], intParts[1], intParts[2], intParts[3], intParts[4]);
else
throw new ArgumentException("Too many parts");
} }
/// <summary> /// <summary>
@ -299,17 +265,12 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
/// <inheritdoc/> /// <inheritdoc/>
public int CompareTo(object? obj) public int CompareTo(object? obj)
{ {
if (obj == null) return obj switch
return 1;
if (obj is GameVersion value)
{ {
return this.CompareTo(value); null => 1,
} GameVersion value => this.CompareTo(value),
else _ => throw new ArgumentException("Argument must be a GameVersion", nameof(obj)),
{ };
throw new ArgumentException("Argument must be a GameVersion");
}
} }
/// <inheritdoc/> /// <inheritdoc/>
@ -342,16 +303,14 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
if (this.Minor != value.Minor) if (this.Minor != value.Minor)
return this.Minor > value.Minor ? 1 : -1; return this.Minor > value.Minor ? 1 : -1;
// This should never happen
return 0; return 0;
} }
/// <inheritdoc/> /// <inheritdoc/>
public override bool Equals(object? obj) public override bool Equals(object? obj)
{ {
if (obj is not GameVersion value) return obj is GameVersion value && this.Equals(value);
return false;
return this.Equals(value);
} }
/// <inheritdoc/> /// <inheritdoc/>
@ -373,16 +332,8 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
/// <inheritdoc/> /// <inheritdoc/>
public override int GetHashCode() public override int GetHashCode()
{ {
var accumulator = 0; // 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);
// 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;
} }
/// <inheritdoc/> /// <inheritdoc/>
@ -396,11 +347,11 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
return "any"; return "any";
return new StringBuilder() return new StringBuilder()
.Append(string.Format("{0:D4}.", this.Year == -1 ? 0 : this.Year)) .Append($"{(this.Year == -1 ? 0 : this.Year):D4}.")
.Append(string.Format("{0:D2}.", this.Month == -1 ? 0 : this.Month)) .Append($"{(this.Month == -1 ? 0 : this.Month):D2}.")
.Append(string.Format("{0:D2}.", this.Day == -1 ? 0 : this.Day)) .Append($"{(this.Day == -1 ? 0 : this.Day):D2}.")
.Append(string.Format("{0:D4}.", this.Major == -1 ? 0 : this.Major)) .Append($"{(this.Major == -1 ? 0 : this.Major):D4}.")
.Append(string.Format("{0:D4}", this.Minor == -1 ? 0 : this.Minor)) .Append($"{(this.Minor == -1 ? 0 : this.Minor):D4}")
.ToString(); .ToString();
} }
} }

View file

@ -15,16 +15,15 @@ public sealed class GameVersionConverter : JsonConverter
/// <param name="serializer">The calling serializer.</param> /// <param name="serializer">The calling serializer.</param>
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{ {
if (value == null) switch (value)
{ {
case null:
writer.WriteNull(); writer.WriteNull();
} break;
else if (value is GameVersion) case GameVersion:
{
writer.WriteValue(value.ToString()); writer.WriteValue(value.ToString());
} break;
else default:
{
throw new JsonSerializationException("Expected GameVersion object value"); throw new JsonSerializationException("Expected GameVersion object value");
} }
} }
@ -43,8 +42,7 @@ public sealed class GameVersionConverter : JsonConverter
{ {
return null; return null;
} }
else
{
if (reader.TokenType == JsonToken.String) if (reader.TokenType == JsonToken.String)
{ {
try try
@ -56,12 +54,9 @@ public sealed class GameVersionConverter : JsonConverter
throw new JsonSerializationException($"Error parsing GameVersion string: {reader.Value}", ex); throw new JsonSerializationException($"Error parsing GameVersion string: {reader.Value}", ex);
} }
} }
else
{
throw new JsonSerializationException($"Unexpected token or value when parsing GameVersion. Token: {reader.TokenType}, Value: {reader.Value}"); throw new JsonSerializationException($"Unexpected token or value when parsing GameVersion. Token: {reader.TokenType}, Value: {reader.Value}");
} }
}
}
/// <summary> /// <summary>
/// Determines whether this instance can convert the specified object type. /// Determines whether this instance can convert the specified object type.

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<AssemblyName>Dalamud.CorePlugin</AssemblyName> <AssemblyName>Dalamud.CorePlugin</AssemblyName>
<TargetFramework>net7.0-windows</TargetFramework> <TargetFramework>net8.0-windows</TargetFramework>
<Platforms>x64</Platforms> <Platforms>x64</Platforms>
<LangVersion>10.0</LangVersion> <LangVersion>10.0</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup Label="Target"> <PropertyGroup Label="Target">
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier> <RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PlatformTarget>x64</PlatformTarget> <PlatformTarget>x64</PlatformTarget>
<Platforms>x64;AnyCPU</Platforms> <Platforms>x64;AnyCPU</Platforms>

View file

@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup Label="Target"> <PropertyGroup Label="Target">
<TargetFramework>net7.0-windows</TargetFramework> <TargetFramework>net8.0-windows</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier> <RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PlatformTarget>x64</PlatformTarget> <PlatformTarget>x64</PlatformTarget>
<Platforms>x64;AnyCPU</Platforms> <Platforms>x64;AnyCPU</Platforms>
<LangVersion>9.0</LangVersion> <LangVersion>11.0</LangVersion>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Label="Feature"> <PropertyGroup Label="Feature">

View file

@ -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<TestSerializationClass>(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<TestSerializationClass>(serialized);
Assert.NotNull(deserialized);
Assert.Null(deserialized.Version);
}
[Fact]
public void ReadJson_WhenInvalidType_Throws()
{
var serialized = """
{
"Version": 2
}
""";
Assert.Throws<JsonSerializationException>(
() => JsonConvert.DeserializeObject<TestSerializationClass>(serialized));
}
[Fact]
public void ReadJson_WhenInvalidVersion_Throws()
{
var serialized = """
{
"Version": "junk"
}
""";
Assert.Throws<JsonSerializationException>(
() => JsonConvert.DeserializeObject<TestSerializationClass>(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<JsonSerializationException>(() => 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; }
}
}

View file

@ -1,10 +1,71 @@
using System;
using Dalamud.Common.Game; using Dalamud.Common.Game;
using Newtonsoft.Json;
using Xunit; using Xunit;
namespace Dalamud.Test.Game namespace Dalamud.Test.Game
{ {
public class GameVersionTests 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] [Theory]
[InlineData("any", "any")] [InlineData("any", "any")]
[InlineData("2021.01.01.0000.0000", "2021.01.01.0000.0000")] [InlineData("2021.01.01.0000.0000", "2021.01.01.0000.0000")]
@ -14,6 +75,18 @@ namespace Dalamud.Test.Game
var v2 = GameVersion.Parse(ver2); var v2 = GameVersion.Parse(ver2);
Assert.Equal(v1, v2); 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] [Theory]
@ -31,6 +104,67 @@ namespace Dalamud.Test.Game
Assert.True(v1.CompareTo(v2) < 0); 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<ArgumentException>(() => 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] [Theory]
[InlineData("2020.06.15.0000.0000")] [InlineData("2020.06.15.0000.0000")]
[InlineData("2021.01.01.0000")] [InlineData("2021.01.01.0000")]
@ -39,9 +173,8 @@ namespace Dalamud.Test.Game
[InlineData("2021")] [InlineData("2021")]
public void VersionConstructor(string ver) public void VersionConstructor(string ver)
{ {
var v = GameVersion.Parse(ver); var v = new GameVersion(ver);
Assert.NotNull(v);
Assert.True(v != null);
} }
[Theory] [Theory]
@ -54,5 +187,89 @@ namespace Dalamud.Test.Game
Assert.False(result); Assert.False(result);
Assert.Null(v); 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<GameVersion>(serialized);
Assert.Equal(v, deserialized);
}
[Fact]
public void VersionInvalidDeserialization()
{
var serialized = """
{
"Year": -1,
"Month": -1,
"Day": -1,
"Major": -1,
"Minor": -1,
}
""";
Assert.Throws<ArgumentOutOfRangeException>(() => JsonConvert.DeserializeObject<GameVersion>(serialized));
}
[Fact]
public void VersionInvalidTypeDeserialization()
{
var serialized = """
{
"Value": "Hello"
}
""";
Assert.Throws<JsonSerializationException>(() => JsonConvert.DeserializeObject<GameVersion>(serialized));
}
[Fact]
public void VersionConstructorNegativeYear()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new GameVersion(-2024));
}
[Fact]
public void VersionConstructorNegativeMonth()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new GameVersion(2024, -3));
}
[Fact]
public void VersionConstructorNegativeDay()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new GameVersion(2024, 3, -13));
}
[Fact]
public void VersionConstructorNegativeMajor()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new GameVersion(2024, 3, 13, -1));
}
[Fact]
public void VersionConstructorNegativeMinor()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new GameVersion(2024, 3, 13, 0, -1));
}
[Fact]
public void VersionParseNull()
{
Assert.Throws<ArgumentNullException>(() => GameVersion.Parse(null!));
}
} }
} }

View file

@ -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<ArgumentException>(() => rfs.Exists(""));
}
[Fact]
public void Exists_ThrowsIfPathIsNull()
{
using var rfs = CreateRfs();
Assert.Throws<ArgumentNullException>(() => 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<ArgumentException>(() => rfs.WriteAllText("", TestFileContent1));
}
[Fact]
public void WriteAllText_ThrowsIfPathIsNull()
{
using var rfs = CreateRfs();
Assert.Throws<ArgumentNullException>(() => 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<ArgumentException>(() => rfs.ReadAllText(""));
}
[Fact]
public void ReadAllText_ThrowsIfPathIsNull()
{
using var rfs = CreateRfs();
Assert.Throws<ArgumentNullException>(() => 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<FileNotFoundException>(() => rfs.ReadAllText(tempFile, containerId: containerId));
}
[Fact]
public void ReadAllText_WhenFileMissing_ThrowsIfDbFailed()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
using var rfs = CreateFailedRfs();
Assert.Throws<FileNotFoundException>(() => 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<FileReadException>(() => 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<FileNotFoundException>(() => 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<FileNotFoundException>(() => 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;
}
}

View file

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup Label="Target"> <PropertyGroup Label="Target">
<TargetFramework>net7.0-windows</TargetFramework> <TargetFramework>net8.0-windows</TargetFramework>
<PlatformTarget>x64</PlatformTarget> <PlatformTarget>x64</PlatformTarget>
<Platforms>x64;AnyCPU</Platforms> <Platforms>x64;AnyCPU</Platforms>
<LangVersion>11.0</LangVersion> <LangVersion>12.0</LangVersion>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Label="Feature"> <PropertyGroup Label="Feature">
@ -75,7 +75,6 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="MinSharp" Version="1.0.4" /> <PackageReference Include="MinSharp" Version="1.0.4" />
<PackageReference Include="MonoModReorg.RuntimeDetour" Version="23.1.2-prerelease.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.2" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.2" />
<PackageReference Include="Serilog" Version="2.11.0" /> <PackageReference Include="Serilog" Version="2.11.0" />
<PackageReference Include="Serilog.Sinks.Async" Version="1.5.0" /> <PackageReference Include="Serilog.Sinks.Async" Version="1.5.0" />
@ -119,14 +118,6 @@
</ItemGroup> </ItemGroup>
</Target> </Target>
<Target Name="ChangeAliasesOfNugetRefs" BeforeTargets="FindReferenceAssembliesForReferences;ResolveReferences">
<ItemGroup>
<ReferencePath Condition="'%(FileName)' == 'MonoMod.Iced'">
<Aliases>monomod</Aliases>
</ReferencePath>
</ItemGroup>
</Target>
<PropertyGroup> <PropertyGroup>
<!-- Needed temporarily for CI --> <!-- Needed temporarily for CI -->
<TempVerFile>$(OutputPath)TEMP_gitver.txt</TempVerFile> <TempVerFile>$(OutputPath)TEMP_gitver.txt</TempVerFile>

View file

@ -1,15 +1,22 @@
using System;
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Runtime.CompilerServices;
using Dalamud.Game.ClientState.Objects.Enums; using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.ClientState.Objects.Types; using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.IoC; using Dalamud.IoC;
using Dalamud.IoC.Internal; using Dalamud.IoC.Internal;
using Dalamud.Plugin.Internal;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using Dalamud.Utility;
using Microsoft.Extensions.ObjectPool;
using Serilog; using Serilog;
using CSGameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject;
namespace Dalamud.Game.ClientState.Objects; namespace Dalamud.Game.ClientState.Objects;
/// <summary> /// <summary>
@ -25,18 +32,41 @@ internal sealed partial class ObjectTable : IServiceType, IObjectTable
{ {
private const int ObjectTableLength = 599; private const int ObjectTableLength = 599;
private readonly ClientStateAddressResolver address; private readonly ClientState clientState;
private readonly CachedEntry[] cachedObjectTable = new CachedEntry[ObjectTableLength];
private readonly ObjectPool<Enumerator> multiThreadedEnumerators =
new DefaultObjectPoolProvider().Create<Enumerator>();
private readonly Enumerator?[] frameworkThreadEnumerators = new Enumerator?[4];
private long nextMultithreadedUsageWarnTime;
[ServiceManager.ServiceConstructor] [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}");
} }
/// <inheritdoc/> /// <inheritdoc/>
public IntPtr Address => this.address.ObjectTable; public nint Address
{
get
{
_ = this.WarnMultithreadedUsage();
return this.clientState.AddressResolver.ObjectTable;
}
}
/// <inheritdoc/> /// <inheritdoc/>
public int Length => ObjectTableLength; public int Length => ObjectTableLength;
@ -46,50 +76,49 @@ internal sealed partial class ObjectTable : IServiceType, IObjectTable
{ {
get get
{ {
var address = this.GetObjectAddress(index); _ = this.WarnMultithreadedUsage();
return this.CreateObjectReference(address);
return index is >= ObjectTableLength or < 0 ? null : this.cachedObjectTable[index].Update();
} }
} }
/// <inheritdoc/> /// <inheritdoc/>
public GameObject? SearchById(ulong objectId) public GameObject? SearchById(ulong objectId)
{ {
_ = this.WarnMultithreadedUsage();
if (objectId is GameObject.InvalidGameObjectId or 0) if (objectId is GameObject.InvalidGameObjectId or 0)
return null; return null;
foreach (var obj in this) foreach (var e in this.cachedObjectTable)
{ {
if (obj == null) if (e.Update() is { } o && o.ObjectId == objectId)
continue; return o;
if (obj.ObjectId == objectId)
return obj;
} }
return null; return null;
} }
/// <inheritdoc/> /// <inheritdoc/>
public unsafe IntPtr GetObjectAddress(int index) public unsafe nint GetObjectAddress(int index)
{ {
if (index < 0 || index >= ObjectTableLength) _ = this.WarnMultithreadedUsage();
return IntPtr.Zero;
return *(IntPtr*)(this.address.ObjectTable + (8 * index)); return index is < 0 or >= ObjectTableLength ? nint.Zero : (nint)this.cachedObjectTable[index].Address;
} }
/// <inheritdoc/> /// <inheritdoc/>
public unsafe GameObject? CreateObjectReference(IntPtr address) public unsafe GameObject? CreateObjectReference(nint address)
{ {
var clientState = Service<ClientState>.GetNullable(); _ = this.WarnMultithreadedUsage();
if (clientState == null || clientState.LocalContentId == 0) if (this.clientState.LocalContentId == 0)
return null; return null;
if (address == IntPtr.Zero) if (address == nint.Zero)
return null; return null;
var obj = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)address; var obj = (CSGameObject*)address;
var objKind = (ObjectKind)obj->ObjectKind; var objKind = (ObjectKind)obj->ObjectKind;
return objKind switch return objKind switch
{ {
@ -104,6 +133,82 @@ internal sealed partial class ObjectTable : IServiceType, IObjectTable
_ => new GameObject(address), _ => 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<PluginManager>.Get().FindCallingPlugin()?.Name ?? "<unknown plugin>",
nameof(ObjectTable));
}
return true;
}
/// <summary>Stores an object table entry, with preallocated concrete types.</summary>
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;
/// <summary>Initializes a new instance of the <see cref="CachedEntry"/> struct.</summary>
/// <param name="ownerTable">The object table that this entry should be pointing to.</param>
/// <param name="slot">The slot index inside the table.</param>
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);
}
/// <summary>Gets the address of the underlying native object. May be null.</summary>
public CSGameObject* Address
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get => *this.gameObjectPtrPtr;
}
/// <summary>Updates and gets the wrapped game object pointed by this struct.</summary>
/// <returns>The pointed object, or <c>null</c> if no object exists at that slot.</returns>
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;
}
}
} }
/// <summary> /// <summary>
@ -117,17 +222,90 @@ internal sealed partial class ObjectTable
/// <inheritdoc/> /// <inheritdoc/>
public IEnumerator<GameObject> GetEnumerator() public IEnumerator<GameObject> 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]; // let's not
var e = this.multiThreadedEnumerators.Get();
if (obj == null) e.InitializeForPooledObjects(this);
continue; return e;
yield return obj;
} }
// 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);
} }
/// <inheritdoc/> /// <inheritdoc/>
IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator();
private sealed class Enumerator : IEnumerator<GameObject>, 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;
}
}
} }

View file

@ -29,7 +29,7 @@ public unsafe partial class GameObject : IEquatable<GameObject>
/// <summary> /// <summary>
/// Gets the address of the game object in memory. /// Gets the address of the game object in memory.
/// </summary> /// </summary>
public IntPtr Address { get; } public IntPtr Address { get; internal set; }
/// <summary> /// <summary>
/// Gets the Dalamud instance. /// Gets the Dalamud instance.

View file

@ -87,6 +87,11 @@ internal sealed class Framework : IInternalDisposableService, IFramework
/// <inheritdoc/> /// <inheritdoc/>
public event IFramework.OnUpdateDelegate? Update; public event IFramework.OnUpdateDelegate? Update;
/// <summary>
/// Executes during FrameworkUpdate before all <see cref="Update"/> delegates.
/// </summary>
internal event IFramework.OnUpdateDelegate BeforeUpdate;
/// <summary> /// <summary>
/// Gets or sets a value indicating whether the collection of stats is enabled. /// Gets or sets a value indicating whether the collection of stats is enabled.
/// </summary> /// </summary>
@ -392,6 +397,8 @@ internal sealed class Framework : IInternalDisposableService, IFramework
ThreadSafety.MarkMainThread(); ThreadSafety.MarkMainThread();
this.BeforeUpdate?.InvokeSafely(this);
this.hitchDetector.Start(); this.hitchDetector.Start();
try try

View file

@ -23,7 +23,8 @@ internal class TaskTracker : IInternalDisposableService
[ServiceManager.ServiceDependency] [ServiceManager.ServiceDependency]
private readonly Framework framework = Service<Framework>.Get(); private readonly Framework framework = Service<Framework>.Get();
private MonoMod.RuntimeDetour.Hook? scheduleAndStartHook; // NET8 CHORE
// private MonoMod.RuntimeDetour.Hook? scheduleAndStartHook;
private bool enabled = false; private bool enabled = false;
[ServiceManager.ServiceConstructor] [ServiceManager.ServiceConstructor]
@ -121,7 +122,8 @@ internal class TaskTracker : IInternalDisposableService
/// <inheritdoc/> /// <inheritdoc/>
void IInternalDisposableService.DisposeService() void IInternalDisposableService.DisposeService()
{ {
this.scheduleAndStartHook?.Dispose(); // NET8 CHORE
// this.scheduleAndStartHook?.Dispose();
this.framework.Update -= this.FrameworkOnUpdate; this.framework.Update -= this.FrameworkOnUpdate;
} }
@ -170,7 +172,8 @@ internal class TaskTracker : IInternalDisposableService
return; return;
} }
this.scheduleAndStartHook = new MonoMod.RuntimeDetour.Hook(targetMethod, patchMethod); // NET8 CHORE
// this.scheduleAndStartHook = new MonoMod.RuntimeDetour.Hook(targetMethod, patchMethod);
Log.Information("AddToActiveTasks Hooked!"); Log.Information("AddToActiveTasks Hooked!");
} }

View file

@ -145,7 +145,8 @@ internal partial class PluginManager : IInternalDisposableService
this.configuration.PluginTestingOptIns ??= new(); this.configuration.PluginTestingOptIns ??= new();
this.MainRepo = PluginRepository.CreateMainRepo(this.happyHttpClient); this.MainRepo = PluginRepository.CreateMainRepo(this.happyHttpClient);
this.ApplyPatches(); // NET8 CHORE
//this.ApplyPatches();
registerStartupBlocker( registerStartupBlocker(
Task.Run(this.LoadAndStartLoadSyncPlugins), Task.Run(this.LoadAndStartLoadSyncPlugins),
@ -422,8 +423,9 @@ internal partial class PluginManager : IInternalDisposableService
} }
} }
this.assemblyLocationMonoHook?.Dispose(); // NET8 CHORE
this.assemblyCodeBaseMonoHook?.Dispose(); // this.assemblyLocationMonoHook?.Dispose();
// this.assemblyCodeBaseMonoHook?.Dispose();
} }
/// <summary> /// <summary>
@ -842,7 +844,8 @@ internal partial class PluginManager : IInternalDisposableService
this.installedPluginsList.Remove(plugin); 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.NotifyinstalledPluginsListChanged();
this.NotifyAvailablePluginsChanged(); this.NotifyAvailablePluginsChanged();
@ -1583,7 +1586,8 @@ internal partial class PluginManager : IInternalDisposableService
} }
catch (InvalidPluginException) catch (InvalidPluginException)
{ {
PluginLocations.Remove(plugin.AssemblyName?.FullName ?? string.Empty, out _); // NET8 CHORE
// PluginLocations.Remove(plugin.AssemblyName?.FullName ?? string.Empty, out _);
throw; throw;
} }
catch (BannedPluginException) catch (BannedPluginException)
@ -1629,7 +1633,8 @@ internal partial class PluginManager : IInternalDisposableService
} }
else else
{ {
PluginLocations.Remove(plugin.AssemblyName?.FullName ?? string.Empty, out _); // NET8 CHORE
// PluginLocations.Remove(plugin.AssemblyName?.FullName ?? string.Empty, out _);
throw; throw;
} }
} }
@ -1756,6 +1761,8 @@ internal partial class PluginManager : IInternalDisposableService
} }
} }
// NET8 CHORE
/*
/// <summary> /// <summary>
/// Class responsible for loading and unloading plugins. /// Class responsible for loading and unloading plugins.
/// This contains the assembly patching functionality to resolve assembly locations. /// 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); this.assemblyCodeBaseMonoHook = new MonoMod.RuntimeDetour.Hook(codebaseTarget, codebasePatch);
} }
} }
*/

View file

@ -404,7 +404,8 @@ internal class LocalPlugin : IDisposable
} }
// Update the location for the Location and CodeBase patches // 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 = this.DalamudInterface =
new DalamudPluginInterface(this, reason); new DalamudPluginInterface(this, reason);

View file

@ -1,12 +1,15 @@
using System.Collections.Generic; using System.Collections.Generic;
using Dalamud.Game.ClientState.Objects.Types; using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Utility;
namespace Dalamud.Plugin.Services; namespace Dalamud.Plugin.Services;
/// <summary> /// <summary>
/// This collection represents the currently spawned FFXIV game objects. /// This collection represents the currently spawned FFXIV game objects.
/// </summary> /// </summary>
[Api10ToDo(
"Make it an IEnumerable<GameObject> instead. Skipping null objects make IReadOnlyCollection<T>.Count yield incorrect values.")]
public interface IObjectTable : IReadOnlyCollection<GameObject> public interface IObjectTable : IReadOnlyCollection<GameObject>
{ {
/// <summary> /// <summary>

View file

@ -90,7 +90,7 @@ internal static class SignatureHelper
switch (sig.UseFlags) 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: case SignatureUseFlags.Pointer:
{ {
if (actualType.IsAssignableTo(typeof(Delegate))) if (actualType.IsAssignableTo(typeof(Delegate)))

View file

@ -1,7 +1,7 @@
{ {
"sdk": { "sdk": {
"version": "7.0.0", "version": "8.0.0",
"rollForward": "latestMajor", "rollForward": "latestMinor",
"allowPrerelease": true "allowPrerelease": true
} }
} }

@ -1 +1 @@
Subproject commit ac2ced26fc98153c65f5b8f0eaf0f464258ff683 Subproject commit 2c885a35e0edf8ab7a335e3296f06642837afbec

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0-windows</TargetFramework> <TargetFramework>net8.0-windows</TargetFramework>
<Platforms>x64</Platforms> <Platforms>x64</Platforms>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>