diff --git a/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs b/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs new file mode 100644 index 000000000..6283dd99a --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Commands; +using Server.Commands.Generic; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands; + +// `sort by` and `distinct` compile the same property chain the conditionals do, and had the same +// crash on a null link in the middle. Ordering cannot answer "no match" the way a condition can, +// so an unreadable binding reads as the default value instead -- which is what a null link means. +[Collection("Sequential UOContent Tests")] +public class ChainedBindingSortTests : IDisposable +{ + private readonly List _items = []; + + public void Dispose() + { + for (var i = 0; i < _items.Count; i++) + { + _items[i].Delete(); + } + + _items.Clear(); + } + + private SkillTeleporter Teleporter(TextDefinition message) + { + var tp = new SkillTeleporter { Message = message }; + _items.Add(tp); + return tp; + } + + private static Property Bind(string binding) + { + var prop = new Property(binding); + prop.BindTo(typeof(SkillTeleporter), PropertyAccess.Read); + return prop; + } + + private static IComparer Sorter(string binding) => + SortCompiler.Compile( + typeof(SkillTeleporter), + [new OrderInfo(Bind(binding), true)] + ); + + private static IComparer Distincter(string binding) => + DistinctCompiler.Compile( + typeof(SkillTeleporter), + [Bind(binding)] + ); + + [Fact] + public void SortingOnAChainSurvivesANullIntermediate() + { + var named = Teleporter(TextDefinition.Of("Alpha")); + var blank = Teleporter(null); + + var comparer = Sorter("Message.String"); + + // A consistent total order is the contract; which end the blanks land on is not asserted. + var forward = comparer.Compare(named, blank); + + Assert.NotEqual(0, forward); + Assert.Equal(-Math.Sign(forward), Math.Sign(comparer.Compare(blank, named))); + Assert.Equal(0, comparer.Compare(blank, blank)); + Assert.Equal(0, comparer.Compare(named, named)); + } + + [Fact] + public void SortingOnAChainStillOrdersReadableValues() + { + var alpha = Teleporter(TextDefinition.Of("Alpha")); + var beta = Teleporter(TextDefinition.Of("Beta")); + + var comparer = Sorter("Message.String"); + + Assert.True(comparer.Compare(alpha, beta) < 0); + Assert.True(comparer.Compare(beta, alpha) > 0); + } + + [Fact] + public void DistinctOnAChainSurvivesANullIntermediate() + { + var named = Teleporter(TextDefinition.Of("Alpha")); + var blank = Teleporter(null); + var alsoBlank = Teleporter(null); + + var comparer = Distincter("Message.String"); + + Assert.NotEqual(0, comparer.Compare(named, blank)); + Assert.Equal(0, comparer.Compare(blank, alsoBlank)); + } + + // An unchained binding never had the problem and must keep working untouched. + [Fact] + public void SortingOnAPlainBindingIsUnchanged() + { + var low = Teleporter(null); + low.Hue = 1; + + var high = Teleporter(null); + high.Hue = 2; + + var comparer = Sorter("Hue"); + + Assert.True(comparer.Compare(low, high) < 0); + Assert.True(comparer.Compare(high, low) > 0); + Assert.Equal(0, comparer.Compare(low, low)); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs b/Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs new file mode 100644 index 000000000..f430973a9 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Commands; +using Server.Commands.Generic; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands; + +// Two shapes the IL emitter got wrong or could not express. A chained binding ending in a value +// type (Message.Number) reused a temp while its value was still live, so every pair compared +// equal and `sort by` was a no-op. A struct with no CompareTo fell through to a raw Ceq, which is +// not valid IL for a non-primitive struct. Both are pinned here against the expression-tree port. +[Collection("Sequential UOContent Tests")] +public class ConditionalCompilerEdgeTests : IDisposable +{ + private readonly List _items = []; + + public void Dispose() + { + for (var i = 0; i < _items.Count; i++) + { + _items[i].Delete(); + } + + _items.Clear(); + } + + private SkillTeleporter Teleporter(TextDefinition message) + { + var tp = new SkillTeleporter { Message = message }; + _items.Add(tp); + return tp; + } + + private static Property Bind(Type type, string binding) + { + var prop = new Property(binding); + prop.BindTo(type, PropertyAccess.Read); + return prop; + } + + [Fact] + public void SortingOnAChainedValueTypeOrdersValues() + { + var low = Teleporter(TextDefinition.Of(1060847)); + var high = Teleporter(TextDefinition.Of(1060848)); + + var comparer = SortCompiler.Compile( + typeof(SkillTeleporter), + [new OrderInfo(Bind(typeof(SkillTeleporter), "Message.Number"), true)] + ); + + Assert.True(comparer.Compare(low, high) < 0); + Assert.True(comparer.Compare(high, low) > 0); + Assert.Equal(0, comparer.Compare(low, low)); + } + + [Fact] + public void DistinctOnAChainedValueTypeSeparatesValues() + { + var low = Teleporter(TextDefinition.Of(1060847)); + var high = Teleporter(TextDefinition.Of(1060848)); + var alsoLow = Teleporter(TextDefinition.Of(1060847)); + + var comparer = DistinctCompiler.Compile( + typeof(SkillTeleporter), + [Bind(typeof(SkillTeleporter), "Message.Number")] + ); + + Assert.NotEqual(0, comparer.Compare(low, high)); + Assert.Equal(0, comparer.Compare(low, alsoLow)); + } + + public class Subject + { + [CommandProperty(AccessLevel.GameMaster)] + public Rectangle2D Bounds { get; set; } = new(new Point2D(1, 2), new Point2D(3, 4)); + } + + private static bool Check(ComparisonOperator op, string value) + { + var compiled = ConditionalCompiler.Compile( + typeof(Subject), + [TypeCondition.Default, new ComparisonCondition(Bind(typeof(Subject), "Bounds"), false, op, value)] + ); + + return compiled.Verify(new Subject()); + } + + [Fact] + public void NonComparableStructComparesByValueEquality() + { + Assert.True(Check(ComparisonOperator.Equal, "(1, 2)+(3, 4)")); + Assert.False(Check(ComparisonOperator.Equal, "(1, 2)+(3, 5)")); + Assert.False(Check(ComparisonOperator.NotEqual, "(1, 2)+(3, 4)")); + Assert.True(Check(ComparisonOperator.NotEqual, "(1, 2)+(3, 5)")); + } + + // Only == and != are meaningful without a CompareTo; a relational operator is an error at + // compile time, not garbage at run time. + [Theory] + [InlineData(ComparisonOperator.Greater)] + [InlineData(ComparisonOperator.GreaterEqual)] + [InlineData(ComparisonOperator.Lesser)] + [InlineData(ComparisonOperator.LesserEqual)] + public void NonComparableStructRejectsRelationalOperators(ComparisonOperator op) + { + Assert.Throws(() => Check(op, "(1, 2)+(3, 4)")); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs b/Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs new file mode 100644 index 000000000..e1068f6e2 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Commands; +using Server.Commands.Generic; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands; + +// The distinct comparer doubles as an IEqualityComparer: objects that compare equal on every +// listed property must hash the same, whatever shape the property is -- an int, a reference that +// may be null, a struct, or a chain with a null link in the middle. +[Collection("Sequential UOContent Tests")] +public class DistinctComparerTests : IDisposable +{ + private readonly List _items = []; + + public void Dispose() + { + for (var i = 0; i < _items.Count; i++) + { + _items[i].Delete(); + } + + _items.Clear(); + } + + private SkillTeleporter Teleporter(Action setup = null) + { + var tp = new SkillTeleporter(); + setup?.Invoke(tp); + _items.Add(tp); + return tp; + } + + private static IEqualityComparer Comparer(params string[] bindings) + { + var props = new Property[bindings.Length]; + + for (var i = 0; i < bindings.Length; i++) + { + props[i] = new Property(bindings[i]); + props[i].BindTo(typeof(SkillTeleporter), PropertyAccess.Read); + } + + return (IEqualityComparer)DistinctCompiler.Compile(typeof(SkillTeleporter), props); + } + + [Fact] + public void EqualObjectsHashTheSameAcrossPropertyShapes() + { + var comparer = Comparer("Hue", "Name", "Location", "Message.String"); + + var one = Teleporter(tp => + { + tp.Hue = 7; + tp.Name = "Gate"; + tp.Location = new Point3D(1, 2, 3); + tp.Message = TextDefinition.Of("Alpha"); + }); + + var two = Teleporter(tp => + { + tp.Hue = 7; + tp.Name = "Gate"; + tp.Location = new Point3D(1, 2, 3); + tp.Message = TextDefinition.Of("Alpha"); + }); + + Assert.True(comparer.Equals(one, two)); + Assert.Equal(comparer.GetHashCode(one), comparer.GetHashCode(two)); + } + + [Fact] + public void NullReferencesAndNullChainLinksHashWithoutThrowing() + { + var comparer = Comparer("Name", "Message.String"); + + var blank = Teleporter(); + var alsoBlank = Teleporter(); + + Assert.True(comparer.Equals(blank, alsoBlank)); + Assert.Equal(comparer.GetHashCode(blank), comparer.GetHashCode(alsoBlank)); + } + + [Fact] + public void DifferingObjectsAreNotEqual() + { + var comparer = Comparer("Hue", "Location"); + + var one = Teleporter(tp => tp.Hue = 1); + var two = Teleporter(tp => tp.Hue = 2); + + Assert.False(comparer.Equals(one, two)); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs b/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs new file mode 100644 index 000000000..997d8b79f --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs @@ -0,0 +1,103 @@ +using System; +using Server; +using Server.Commands; +using Server.Commands.Generic; +using Xunit; + +namespace UOContent.Tests.Commands; + +// The conditional compiler emits into a fresh dynamic assembly for every command invocation -- +// there is no per-type cache, so `Run` (which can never be unloaded) grows the process by one +// assembly per `[global where`. And PropertyValue could only load a handful of primitive constant +// types, so a comparison against anything narrower or unsigned than int threw instead of running. +public class EmitterRobustnessTests +{ + public class Subject + { + [CommandProperty(AccessLevel.GameMaster)] + public byte Tiny { get; set; } = 5; + + [CommandProperty(AccessLevel.GameMaster)] + public sbyte Signed { get; set; } = -5; + + [CommandProperty(AccessLevel.GameMaster)] + public short Small { get; set; } = -300; + + [CommandProperty(AccessLevel.GameMaster)] + public ushort Key { get; set; } = 40000; + + [CommandProperty(AccessLevel.GameMaster)] + public uint Big { get; set; } = 3_000_000_000; + + [CommandProperty(AccessLevel.GameMaster)] + public ulong Huge { get; set; } = 18_000_000_000_000_000_000; + } + + private static bool Check(string binding, ComparisonOperator op, string value) + { + var prop = new Property(binding); + prop.BindTo(typeof(Subject), PropertyAccess.Read); + + var compiled = ConditionalCompiler.Compile( + typeof(Subject), + [TypeCondition.Default, new ComparisonCondition(prop, false, op, value)] + ); + + return compiled.Verify(new Subject()); + } + + [Theory] + [InlineData("Tiny", "5")] + [InlineData("Signed", "-5")] + [InlineData("Small", "-300")] + [InlineData("Key", "40000")] + [InlineData("Big", "3000000000")] + [InlineData("Huge", "18000000000000000000")] + public void NarrowAndUnsignedIntegersCompareByEquality(string binding, string value) + { + Assert.True(Check(binding, ComparisonOperator.Equal, value)); + Assert.False(Check(binding, ComparisonOperator.NotEqual, value)); + } + + [Theory] + [InlineData("Tiny", "4")] + [InlineData("Signed", "-6")] + [InlineData("Small", "-301")] + [InlineData("Key", "39999")] + [InlineData("Big", "2999999999")] + [InlineData("Huge", "17999999999999999999")] + public void NarrowAndUnsignedIntegersCompareRelationally(string binding, string value) + { + Assert.True(Check(binding, ComparisonOperator.Greater, value)); + Assert.False(Check(binding, ComparisonOperator.Lesser, value)); + } + + // Unsigned values above the signed range must not wrap into a negative comparison. + [Fact] + public void UnsignedComparisonsDoNotWrapThroughSignedMath() + { + Assert.True(Check("Big", ComparisonOperator.Greater, "2147483647")); + Assert.True(Check("Huge", ComparisonOperator.Greater, "9223372036854775807")); + } + + [Fact] + public void EmittedAssembliesAreCollectible() + { + var prop = new Property("Tiny"); + prop.BindTo(typeof(Subject), PropertyAccess.Read); + + var compiled = ConditionalCompiler.Build( + typeof(Subject), + [TypeCondition.Default, new ComparisonCondition(prop, false, ComparisonOperator.Equal, "5")] + ).Compile(); + + var method = compiled.Method; + + Assert.True( + method.IsCollectible, + "The conditional compiler compiles one delegate per command invocation; code that is " + + $"not collectible ({method.GetType().Name} in {method.Module}) can never be unloaded, " + + "so every [global where would grow the process for good." + ); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/NullablePropertyConditionTests.cs b/Projects/UOContent.Tests/Tests/Commands/NullablePropertyConditionTests.cs new file mode 100644 index 000000000..b1f34b2f3 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/NullablePropertyConditionTests.cs @@ -0,0 +1,143 @@ +using System; +using Server; +using Server.Commands; +using Server.Commands.Generic; +using Xunit; + +namespace UOContent.Tests.Commands; + +// A `where` clause against a Nullable property. No [CommandProperty] in the tree is nullable +// yet, so nothing is broken in practice -- but the conditional compiler should not fall over on +// one either: a set value compares as its underlying type, an unset one equals `null` and +// satisfies no relation, just as C#'s lifted operators would have it. +public class NullablePropertyConditionTests +{ + public class Subject + { + [CommandProperty(AccessLevel.GameMaster)] + public int? Count { get; set; } + + [CommandProperty(AccessLevel.GameMaster)] + public TimeSpan? Delay { get; set; } + + [CommandProperty(AccessLevel.GameMaster)] + public SkillName? Skill { get; set; } + } + + private static bool Check(Subject subject, string binding, ComparisonOperator op, string value) + { + var prop = new Property(binding); + prop.BindTo(typeof(Subject), PropertyAccess.Read); + + var conditional = new ObjectConditional( + typeof(Subject), + [[TypeCondition.Default, new ComparisonCondition(prop, false, op, value)]] + ); + + return conditional.CheckCondition(subject); + } + + [Fact] + public void SetValueComparesByEquality() + { + var subject = new Subject { Count = 5 }; + + Assert.True(Check(subject, "Count", ComparisonOperator.Equal, "5")); + Assert.False(Check(subject, "Count", ComparisonOperator.Equal, "4")); + Assert.True(Check(subject, "Count", ComparisonOperator.NotEqual, "4")); + Assert.False(Check(subject, "Count", ComparisonOperator.NotEqual, "5")); + } + + [Fact] + public void SetValueComparesRelationally() + { + var subject = new Subject { Count = 5 }; + + Assert.True(Check(subject, "Count", ComparisonOperator.Greater, "4")); + Assert.False(Check(subject, "Count", ComparisonOperator.Lesser, "4")); + Assert.True(Check(subject, "Count", ComparisonOperator.GreaterEqual, "5")); + Assert.True(Check(subject, "Count", ComparisonOperator.LesserEqual, "5")); + } + + [Fact] + public void UnsetValueEqualsNull() + { + Assert.True(Check(new Subject(), "Count", ComparisonOperator.Equal, "null")); + Assert.False(Check(new Subject { Count = 5 }, "Count", ComparisonOperator.Equal, "null")); + Assert.True(Check(new Subject { Count = 5 }, "Count", ComparisonOperator.NotEqual, "null")); + } + + // Lifted semantics: null is never greater, lesser, or equal to a value -- only unequal. + [Fact] + public void UnsetValueSatisfiesNoRelation() + { + var subject = new Subject(); + + Assert.False(Check(subject, "Count", ComparisonOperator.Equal, "0")); + Assert.True(Check(subject, "Count", ComparisonOperator.NotEqual, "0")); + Assert.False(Check(subject, "Count", ComparisonOperator.Greater, "0")); + Assert.False(Check(subject, "Count", ComparisonOperator.GreaterEqual, "0")); + Assert.False(Check(subject, "Count", ComparisonOperator.Lesser, "0")); + Assert.False(Check(subject, "Count", ComparisonOperator.LesserEqual, "0")); + } + + // A struct that compares through CompareTo rather than a primitive operator. + [Fact] + public void NullableStructComparesThroughCompareTo() + { + var set = new Subject { Delay = TimeSpan.FromSeconds(5) }; + + Assert.True(Check(set, "Delay", ComparisonOperator.Equal, "00:00:05")); + Assert.True(Check(set, "Delay", ComparisonOperator.Greater, "00:00:04")); + Assert.False(Check(set, "Delay", ComparisonOperator.Lesser, "00:00:04")); + Assert.False(Check(set, "Delay", ComparisonOperator.Equal, "null")); + + var unset = new Subject(); + + Assert.True(Check(unset, "Delay", ComparisonOperator.Equal, "null")); + Assert.False(Check(unset, "Delay", ComparisonOperator.Greater, "00:00:04")); + Assert.False(Check(unset, "Delay", ComparisonOperator.Lesser, "00:00:04")); + } + + [Fact] + public void NullableEnumComparesByNameAndOrder() + { + var set = new Subject { Skill = SkillName.Magery }; + + Assert.True(Check(set, "Skill", ComparisonOperator.Equal, "Magery")); + Assert.False(Check(set, "Skill", ComparisonOperator.Equal, "Anatomy")); + Assert.True(Check(set, "Skill", ComparisonOperator.Greater, "Alchemy")); + Assert.False(Check(set, "Skill", ComparisonOperator.Equal, "null")); + + var unset = new Subject(); + + Assert.True(Check(unset, "Skill", ComparisonOperator.Equal, "null")); + Assert.False(Check(unset, "Skill", ComparisonOperator.Equal, "Magery")); + Assert.False(Check(unset, "Skill", ComparisonOperator.Greater, "Alchemy")); + } + + // `sort by` on a nullable: values order by the underlying type and an unset value takes a + // consistent place at one end, the same convention a null reference already had. + [Fact] + public void SortingOnANullableOrdersValuesAndPlacesUnsetConsistently() + { + var prop = new Property("Count"); + prop.BindTo(typeof(Subject), PropertyAccess.Read); + + var comparer = SortCompiler.Compile(typeof(Subject), [new OrderInfo(prop, true)]); + + var low = new Subject { Count = 1 }; + var high = new Subject { Count = 2 }; + var unset = new Subject(); + + Assert.True(comparer.Compare(low, high) < 0); + Assert.True(comparer.Compare(high, low) > 0); + Assert.Equal(0, comparer.Compare(low, low)); + + var forward = comparer.Compare(high, unset); + + Assert.NotEqual(0, forward); + Assert.Equal(-Math.Sign(forward), Math.Sign(comparer.Compare(unset, high))); + Assert.Equal(0, comparer.Compare(unset, new Subject())); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs b/Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs new file mode 100644 index 000000000..3d43eca42 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Commands.Generic; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands; + +// `where` compiles conditions to IL. Two shapes were broken there: a property type with value +// equality but no IComparable fell through to a raw Ceq (reference equality, so never true), and +// a chained binding dereferenced every link with no null guard (so the first object with a null +// intermediate took the whole sweep down with an NRE). +[Collection("Sequential UOContent Tests")] +public class ObjectConditionalTests : IDisposable +{ + private readonly List _items = []; + private readonly Mobile _from = new() { AccessLevel = AccessLevel.Developer }; + + public void Dispose() + { + for (var i = 0; i < _items.Count; i++) + { + _items[i].Delete(); + } + + _items.Clear(); + _from.Delete(); + } + + private SkillTeleporter Teleporter(TextDefinition message) + { + var tp = new SkillTeleporter { Message = message }; + _items.Add(tp); + return tp; + } + + private bool Check(object target, params string[] condition) + { + var args = new string[condition.Length + 1]; + args[0] = nameof(SkillTeleporter); + Array.Copy(condition, 0, args, 1, condition.Length); + + return ObjectConditional.ParseDirect(_from, args, 0, args.Length).CheckCondition(target); + } + + [Fact] + public void EqualityUsesValueSemanticsNotReferenceIdentity() + { + var tp = Teleporter(TextDefinition.Of(1060847)); + + Assert.True(Check(tp, "Message", "=", "1060847")); + } + + [Fact] + public void InequalityUsesValueSemanticsNotReferenceIdentity() + { + var tp = Teleporter(TextDefinition.Of(1060847)); + + Assert.False(Check(tp, "Message", "!=", "1060847")); + Assert.True(Check(tp, "Message", "!=", "1234567")); + } + + [Fact] + public void StringValuedDefinitionsCompareByValue() + { + var tp = Teleporter(TextDefinition.Of("Hail, traveller.")); + + Assert.True(Check(tp, "Message", "=", "Hail, traveller.")); + Assert.False(Check(tp, "Message", "=", "Farewell.")); + } + + [Fact] + public void NullComparisonStillWorks() + { + Assert.True(Check(Teleporter(null), "Message", "=", "null")); + Assert.False(Check(Teleporter(TextDefinition.Of(1060847)), "Message", "=", "null")); + } + + // The sweep case: [global where SkillTeleporter Message.Number = X hits teleporters whose + // Message was never set long before it hits one that matches. + [Fact] + public void ChainedBindingOnANullIntermediateIsFalseNotAnException() + { + var blank = Teleporter(null); + + Assert.False(Check(blank, "Message.Number", "=", "1060847")); + } + + [Fact] + public void ChainedBindingStillMatchesWhenIntermediateIsPresent() + { + var tp = Teleporter(TextDefinition.Of(1060847)); + + Assert.True(Check(tp, "Message.Number", "=", "1060847")); + Assert.False(Check(tp, "Message.Number", "=", "1234567")); + } + + [Fact] + public void ChainedBindingOnANullIntermediateIsFalseUnderNegationToo() + { + var blank = Teleporter(null); + + Assert.False(Check(blank, "not", "Message.Number", "=", "1060847")); + } + + // The string operators (contains / starts / ends / =~) compile through StringCondition, which + // chains the same way ComparisonCondition does and so had the same null-intermediate crash. + [Theory] + [InlineData("contains")] + [InlineData("contains~")] + [InlineData("starts")] + [InlineData("ends")] + [InlineData("=~")] + [InlineData("!=~")] + public void StringOperatorsOnANullIntermediateAreFalseNotAnException(string oper) + { + var blank = Teleporter(null); + + Assert.False(Check(blank, "Message.String", oper, "gate")); + } + + [Fact] + public void StringOperatorsOnANullIntermediateAreFalseUnderNegationToo() + { + var blank = Teleporter(null); + + Assert.False(Check(blank, "not", "Message.String", "contains", "gate")); + } + + [Fact] + public void StringOperatorsStillMatchThroughAChain() + { + var tp = Teleporter(TextDefinition.Of("Moongate")); + + Assert.True(Check(tp, "Message.String", "contains", "gate")); + Assert.True(Check(tp, "Message.String", "starts", "Moon")); + Assert.True(Check(tp, "Message.String", "ends", "gate")); + Assert.True(Check(tp, "Message.String", "=~", "moongate")); + Assert.False(Check(tp, "Message.String", "contains", "portal")); + } + + // The chain resolves here -- Message is set -- but its String is null because the definition + // holds a cliloc. That is the final value, which StringCondition already guarded. + [Fact] + public void StringOperatorsHandleANullFinalValue() + { + var tp = Teleporter(TextDefinition.Of(1060847)); + + Assert.False(Check(tp, "Message.String", "contains", "gate")); + Assert.False(Check(tp, "Message.String", "=~", "gate")); + } + + [Fact] + public void UnchainedStringOperatorsStillWork() + { + var tp = Teleporter(null); + tp.Name = "Moongate"; + + Assert.True(Check(tp, "Name", "contains", "gate")); + Assert.True(Check(tp, "Name", "=~", "moongate")); + Assert.False(Check(tp, "Name", "contains", "portal")); + } + + // Guards for the comparison paths the equality change must not disturb. Ints, strings and + // enums are IComparable, so they route through CompareTo and never reach CompareEquality -- + // these prove that routing is intact. + [Fact] + public void NumericComparisonsStillWork() + { + var tp = Teleporter(null); + tp.Hue = 42; + + Assert.True(Check(tp, "Hue", "=", "42")); + Assert.False(Check(tp, "Hue", "=", "43")); + Assert.True(Check(tp, "Hue", ">", "41")); + Assert.True(Check(tp, "Hue", "<", "43")); + Assert.True(Check(tp, "Hue", "!=", "43")); + } + + [Fact] + public void StringComparisonsStillWork() + { + var tp = Teleporter(null); + tp.Name = "gate"; + + Assert.True(Check(tp, "Name", "=", "gate")); + Assert.False(Check(tp, "Name", "=", "portal")); + } + + [Fact] + public void EnumComparisonsStillWork() + { + var tp = Teleporter(null); + tp.Skill = SkillName.Magery; + + Assert.True(Check(tp, "Skill", "=", "Magery")); + Assert.False(Check(tp, "Skill", "=", "Anatomy")); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs new file mode 100644 index 000000000..df795a440 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs @@ -0,0 +1,232 @@ +using System; +using Server; +using Server.Engines.AdvancedSearch; +using Xunit; + +namespace UOContent.Tests; + +// The Advanced Search property test compiles through the same conditions a `where` clause does. +// The grammar is the gump's own; these pin its operators and precedence, and that a leaf which +// cannot be resolved or parsed is "no match" rather than an exception on the search worker. +public class AdvancedSearchConditionsTests +{ + public sealed class Inner + { + public int Value { get; set; } = 7; + } + + public sealed class LegacyParseType + { + public string Value { get; private init; } + public static LegacyParseType Parse(string s) => new() { Value = s }; + public override bool Equals(object obj) => obj is LegacyParseType o && o.Value == Value; + public override int GetHashCode() => Value?.GetHashCode() ?? 0; + } + + public class Subject + { + public int Hue { get; set; } = 5; + public string Name { get; set; } = "Moongate"; + public bool Movable { get; set; } = true; + public bool Visible { get; set; } + public double Weight { get; set; } = 0.1 + 0.2; + public float Ratio { get; set; } = 0.1f; + public TimeSpan Delay { get; set; } = TimeSpan.FromMinutes(5); + public Guid Id { get; set; } = Guid.Parse("00000000-0000-0000-0000-000000000001"); + public Layer Layer { get; set; } = Layer.OneHanded; + public object Reference { get; set; } = new(); + public LegacyParseType Legacy { get; set; } = LegacyParseType.Parse("alpha"); + public Inner Child { get; set; } = new(); + public int? Maybe { get; set; } + } + + public sealed class Derived : Subject + { + } + + private static bool Check(string test, Subject subject = null) => + AdvancedSearchConditions.Compile(typeof(Subject), test)(subject ?? new Subject()); + + [Theory] + [InlineData("Hue=abc")] // not a number + [InlineData("Hue=99999999999")] // overflows int + [InlineData("Hue=0xZZ")] // bad hex + [InlineData("Layer=Bogus")] // not an enum member + [InlineData("Delay=notaspan")] + [InlineData("Bogus=1")] // no such property + [InlineData("Hue=")] // no value + [InlineData("Hue")] // no operator + public void UnusableLeafIsNoMatchAndDoesNotThrow(string test) + { + var ex = Record.Exception(() => Assert.False(Check(test))); + Assert.Null(ex); + } + + // The old evaluator negated a failed comparison, so `~Hue=abc` matched every entity. + [Fact] + public void UnusableLeafStaysNoMatchUnderNegation() + { + Assert.False(Check("~Hue=abc")); + Assert.False(Check("~Bogus=1")); + } + + [Fact] + public void NumericOperators() + { + Assert.True(Check("Hue=5")); + Assert.True(Check("Hue==5")); + Assert.True(Check("Hue=0x5")); + Assert.False(Check("Hue!=5")); + Assert.True(Check("Hue!4")); + Assert.True(Check("Hue>4")); + Assert.True(Check("Hue<6")); + Assert.True(Check("Hue>=5")); + Assert.True(Check("Hue<=5")); + Assert.False(Check("Hue~5")); + } + + [Fact] + public void NegationPrefix() + { + Assert.False(Check("~Hue=5")); + Assert.True(Check("~Hue=4")); + } + + // '|' binds looser than '@'. + [Theory] + [InlineData("Movable=1", true)] + [InlineData("Visible=1", false)] + [InlineData("Visible=1@Visible=1|Movable=1", true)] // (F&&F)||T + [InlineData("Movable=1|Visible=1@Visible=1", true)] // T||(F&&F) + [InlineData("Movable=1@Visible=1", false)] + [InlineData("Movable=1@Movable=1", true)] + [InlineData("Visible=1|Visible=1", false)] + public void Precedence(string test, bool expected) + { + Assert.Equal(expected, Check(test)); + } + + [Theory] + [InlineData("Name=Moongate", true)] + [InlineData("Name=moongate", false)] + [InlineData("Name!Moongate", false)] + [InlineData("Name>Moon", true)] + [InlineData("Namemoon", true)] + [InlineData("Name~=Moon", false)] // no such string operator + public void StringOperators(string test, bool expected) + { + Assert.Equal(expected, Check(test)); + } + + [Fact] + public void StringNullIsTheNullStringForEqualityAndTextOtherwise() + { + var unnamed = new Subject { Name = null }; + + Assert.True(Check("Name=null", unnamed)); + Assert.False(Check("Name=null")); + Assert.False(Check("Name~null", unnamed)); + Assert.True(Check("Name~null", new Subject { Name = "nullable" })); + } + + [Fact] + public void BooleanAcceptsSwitchWordsAndOnlyEquality() + { + Assert.True(Check("Movable=true")); + Assert.True(Check("Movable=1")); + Assert.True(Check("Movable=on")); + Assert.True(Check("Movable=Enabled")); + Assert.True(Check("Visible=off")); + Assert.False(Check("Movable=maybe")); + Assert.False(Check("Movable>0")); + } + + [Fact] + public void EnumIgnoresCaseAndOrdersByValue() + { + Assert.True(Check("Layer=onehanded")); + Assert.True(Check("Layer>Invalid")); + Assert.False(Check("Layer=TwoHanded")); + } + + // Floating point compares to a tolerance derived from the typed value. + [Fact] + public void FloatingPointUsesEpsilon() + { + Assert.True(Check("Weight=0.3")); + Assert.False(Check("Weight=0.31")); + Assert.True(Check("Weight>0.2")); + Assert.True(Check("Weight<=0.3")); + Assert.True(Check("Ratio=0.1")); + Assert.False(Check("Ratio=0.2")); + } + + [Fact] + public void TimeSpanParsesAndCompares() + { + Assert.True(Check("Delay=00:05:00")); + Assert.False(Check("Delay=00:10:00")); + Assert.True(Check("Delay>00:01:00")); + } + + [Fact] + public void ValueTypeWithoutHotPathParsesThroughTypes() + { + Assert.True(Check("Id=00000000-0000-0000-0000-000000000001")); + Assert.False(Check("Id=00000000-0000-0000-0000-000000000002")); + } + + // A pre-IParsable type with only a static Parse(string) is still searchable, compared against + // a real parsed instance rather than the raw text. + [Fact] + public void LegacyParseStringParsesThroughTypes() + { + Assert.True(Check("Legacy=alpha")); + Assert.False(Check("Legacy=beta")); + } + + // A reference type with no CompareTo answers equality only; ordering is no match, not a throw. + [Fact] + public void ReferenceTypeOrderingIsNoMatchAndDoesNotThrow() + { + var ex = Record.Exception(() => Assert.False(Check("Reference>whatever"))); + Assert.Null(ex); + } + + [Fact] + public void DottedNameWalksIntoTheProperty() + { + Assert.True(Check("Child.Value=7")); + Assert.False(Check("Child.Value=8")); + Assert.False(Check("Child.Value=7", new Subject { Child = null })); + Assert.False(Check("~Child.Value=7", new Subject { Child = null })); + } + + [Fact] + public void NullableCompares() + { + Assert.True(Check("Maybe=null")); + Assert.False(Check("Maybe=5")); + Assert.True(Check("Maybe=5", new Subject { Maybe = 5 })); + Assert.True(Check("Maybe>4", new Subject { Maybe = 5 })); + } + + // Two runtime types resolving the same declared property share one compiled predicate. + [Fact] + public void SubclassesShareThePredicateCompiledForTheDeclaringType() + { + var cache = new AdvancedSearchConditions.Cache(); + + var forBase = AdvancedSearchConditions.GetPredicate(cache, typeof(Subject), "Hue=5"); + var forDerived = AdvancedSearchConditions.GetPredicate(cache, typeof(Derived), "Hue=5"); + + Assert.Same(forBase, forDerived); + Assert.True(forDerived(new Derived())); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs index ea14f3cf2..36f97eaf0 100644 --- a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs @@ -7,21 +7,27 @@ namespace UOContent.Tests; [Collection("Sequential UOContent Tests")] public class AdvancedSearchTypesTests { + public class Subject + { + public Poison Venom { get; set; } + } + [Fact] - public void CompareValues_Poison_ReferenceTypeParsedViaTypes() + public void Poison_ReferenceTypeParsedViaTypes() { PoisonKinds.Configure(); // idempotent; registers Lesser..Lethal now that Core.Expansion is set - // Poison is a reference type implementing ISpanParsable; it can't use the compile-time span - // path and routes through the shared Server.Types converter. Poison.Parse returns the - // registered singleton, so "= Lethal" is a reference-equality match — this is the case that - // previously compared a Poison against the raw string and always failed. - var prop = Poison.Lethal; - Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "Lethal", "=")); - Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "Lesser", "=")); + // Poison is a reference type implementing ISpanParsable; its value routes through the shared + // Server.Types converter. Poison.Parse returns the registered singleton, so "= Lethal" is a + // reference-equality match — this is the case that previously compared a Poison against the + // raw string and always failed. + var subject = new Subject { Venom = Poison.Lethal }; + + Assert.True(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=Lethal")(subject)); + Assert.False(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=Lesser")(subject)); var ex = Record.Exception(() => - Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "notapoison", "="))); + Assert.False(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=notapoison")(subject))); Assert.Null(ex); } } diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs deleted file mode 100644 index 9176fae4a..000000000 --- a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System; -using Server; -using Server.Engines.AdvancedSearch; -using Xunit; - -namespace UOContent.Tests; - -public class AdvancedSearchUtilitiesTests -{ - [Theory] - [InlineData("abc")] // not a number -> was FormatException - [InlineData("99999999999")] // overflows int -> was OverflowException - [InlineData("0xZZ")] // bad hex -> was FormatException - public void CompareValues_BadNumeric_ReturnsFalse_DoesNotThrow(string value) - { - var ex = Record.Exception(() => - { - var result = AdvancedSearchUtilities.CompareValues(typeof(int), 5, value, ">"); - Assert.False(result); - }); - Assert.Null(ex); - } - - [Theory] - [InlineData("Bogus")] // not a member -> was ArgumentException - [InlineData("onehandedxyz")] // not a member, even case-insensitively -> was ArgumentException - public void CompareValues_BadEnum_ReturnsFalse_DoesNotThrow(string value) - { - var ex = Record.Exception(() => - { - var result = AdvancedSearchUtilities.CompareValues(typeof(Layer), (byte)Layer.OneHanded, value, "="); - Assert.False(result); - }); - Assert.Null(ex); - } - - [Fact] - public void CompareValues_ValidEnum_IgnoresCase() - { - Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Layer), (byte)Layer.OneHanded, "onehanded", "=")); - } - - [Theory] - // leaf value is "T"/"F"; evalLeaf returns leaf=="T" - [InlineData("T", true)] - [InlineData("F", false)] - [InlineData("F@F|T", true)] // (F&&F)||T = T (buggy code gave F&&(F||T)=F) - [InlineData("T|F@F", true)] // T||(F&&F) = T (buggy code gave (T||F)&&F=F) - [InlineData("T@F", false)] - [InlineData("T@T", true)] - [InlineData("F|F", false)] - public void EvaluateBoolean_Precedence(string expr, bool expected) - { - // State is unused here; the leaf evaluator just checks the span equals "T". - var result = AdvancedSearchUtilities.EvaluateBoolean(expr, 0, static (_, leaf) => leaf.SequenceEqual("T")); - Assert.Equal(expected, result); - } - - [Fact] - public void CompareValues_ReferenceType_EqualityByString_NoThrow() - { - // A reference-typed property (e.g. RootParent name-ish) compared with "=" should not throw, - // and ordering operators must return false rather than throwing. - var ex = Record.Exception(() => - { - Assert.False(AdvancedSearchUtilities.CompareValues(typeof(object), new object(), "whatever", ">")); - }); - Assert.Null(ex); - } - - [Fact] - public void CompareValues_TimeSpan_ParsesViaSpanParsable() - { - // TimeSpan is not IConvertible, so the old Convert.ChangeType fallback threw and silently - // returned no-match. ISpanParsable parses it correctly. - var prop = TimeSpan.FromMinutes(5); - Assert.True(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:05:00", "=")); - Assert.False(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:10:00", "=")); - Assert.True(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:01:00", ">")); - } - - [Fact] - public void CompareValues_TimeSpan_BadInput_ReturnsFalse_NoThrow() - { - var ex = Record.Exception(() => - Assert.False(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), TimeSpan.Zero, "notaspan", "="))); - Assert.Null(ex); - } - - [Fact] - public void CompareValues_Guid_ValueTypeParsedViaTypes() - { - // Guid is a value type not named by the hot paths; it's parsed via Types (IParsable) and - // compared by value. - var g = Guid.Parse("00000000-0000-0000-0000-000000000001"); - Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Guid), g, "00000000-0000-0000-0000-000000000001", "=")); - Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Guid), g, "00000000-0000-0000-0000-000000000002", "=")); - } - - // A reference type with a legacy RunUO-style static Parse(string) and NO IParsable<> interface — - // the Faction/Town shape. Types must still discover its Parse by reflection. - private sealed class LegacyParseType - { - public string Value { get; private init; } - public static LegacyParseType Parse(string s) => new() { Value = s }; - public override bool Equals(object obj) => obj is LegacyParseType o && o.Value == Value; - public override int GetHashCode() => Value?.GetHashCode() ?? 0; - } - - [Fact] - public void CompareValues_LegacyParseString_ParsedViaTypes() - { - // Pre-IParsable types (only a static Parse(string)) must still be searchable: Types binds the - // legacy Parse by reflection, so we compare against a real parsed instance, not the raw text. - var prop = LegacyParseType.Parse("alpha"); - Assert.True(AdvancedSearchUtilities.CompareValues(typeof(LegacyParseType), prop, "alpha", "=")); - Assert.False(AdvancedSearchUtilities.CompareValues(typeof(LegacyParseType), prop, "beta", "=")); - } -} diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs index 2bfe94af8..bd96a8586 100644 --- a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using Server; using Server.Engines.AdvancedSearch; using Server.Items; @@ -48,6 +49,110 @@ public class AdvancedSearchWorkerTests } } + // End to end through Wake/Push/Sleep on real entities: the property test is compiled on the + // worker, memoized per type, and applied to items and mobiles alike. + [Fact] + public void Worker_PropertyTest_FiltersItemsAndMobiles() + { + var worker = new AdvancedSearchThreadWorker(); + var results = new ConcurrentQueue(); + var ignore = new ConcurrentQueue(); + var filter = new AdvancedSearchFilter + { + FilterPropertyTest = true, + PropertyTest = "Hue > 0", + }; + + var plain = new Item(0x1); + var hued = new Item(0x1) { Hue = 42 }; + var huedToo = new Gold(1) { Hue = 7 }; + var mobile = new Mobile { Hue = 1002 }; + + try + { + worker.Wake(new WorldLocation(Point3D.Zero, Map.Felucca), filter, results, ignore); + worker.Push(plain); + worker.Push(hued); + worker.Push(huedToo); + worker.Push(mobile); + worker.Sleep(); + + var matched = new HashSet(); + foreach (var r in results) + { + matched.Add(r.Entity); + } + + Assert.Equal(3, matched.Count); + Assert.Contains(hued, matched); + Assert.Contains(huedToo, matched); + Assert.Contains(mobile, matched); + Assert.DoesNotContain(plain, matched); + } + finally + { + plain.Delete(); + hued.Delete(); + huedToo.Delete(); + mobile.Delete(); + worker.Exit(); + } + } + + // The map boxes are independent checks. Ticking several used to reject everything, because + // each ticked map was applied as "must be on this map"; an entity on any ticked map passes. + [Fact] + public void Worker_SeveralMapsTicked_MatchesAnyOfThem() + { + var worker = new AdvancedSearchThreadWorker(); + var results = new ConcurrentQueue(); + var ignore = new ConcurrentQueue(); + var filter = new AdvancedSearchFilter + { + FilterFelucca = true, + FilterTrammel = true, + FilterInternalMap = true, + HideValidInternalMap = false, + }; + + var fel = new Item(0x1); + fel.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); + var tram = new Item(0x1); + tram.MoveToWorld(new Point3D(1000, 1000, 0), Map.Trammel); + var internalItem = new Item(0x1); // starts on Map.Internal + var malas = new Item(0x1); + malas.MoveToWorld(new Point3D(1000, 1000, 0), Map.Malas); + + try + { + worker.Wake(new WorldLocation(Point3D.Zero, Map.Felucca), filter, results, ignore); + worker.Push(fel); + worker.Push(tram); + worker.Push(internalItem); + worker.Push(malas); + worker.Sleep(); + + var matched = new HashSet(); + foreach (var r in results) + { + matched.Add(r.Entity); + } + + Assert.Contains(fel, matched); + Assert.Contains(tram, matched); + Assert.Contains(internalItem, matched); + Assert.DoesNotContain(malas, matched); + } + finally + { + fel.Delete(); + tram.Delete(); + internalItem.Delete(); + malas.Delete(); + worker.Exit(); + } + } + [Fact] public void Worker_DeletedEntity_IsSkipped() { diff --git a/Projects/UOContent/Commands/Generic/Extensions/BaseExtension.cs b/Projects/UOContent/Commands/Generic/Extensions/BaseExtension.cs index 8709f0d14..7a37cd4f9 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/BaseExtension.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/BaseExtension.cs @@ -95,11 +95,9 @@ namespace Server.Commands.Generic parsed.Sort((a, b) => a.Order - b.Order); - AssemblyEmitter emitter = null; - foreach (var update in parsed) { - update.Optimize(from, baseType, ref emitter); + update.Optimize(from, baseType); } if (size != args.Length) @@ -129,7 +127,7 @@ namespace Server.Commands.Generic public int Order => Info.Order; - public virtual void Optimize(Mobile from, Type baseType, ref AssemblyEmitter assembly) + public virtual void Optimize(Mobile from, Type baseType) { } diff --git a/Projects/UOContent/Commands/Generic/Extensions/Compilers/ConditionalCompiler.cs b/Projects/UOContent/Commands/Generic/Extensions/Compilers/ConditionalCompiler.cs index 6f5af48d4..fecf0ee2e 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/Compilers/ConditionalCompiler.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/Compilers/ConditionalCompiler.cs @@ -1,7 +1,6 @@ using System; -using System.Globalization; +using System.Linq.Expressions; using System.Reflection; -using System.Reflection.Emit; namespace Server.Commands.Generic { @@ -12,209 +11,17 @@ namespace Server.Commands.Generic public interface ICondition { - // Invoked during the constructor - void Construct(TypeBuilder typeBuilder, ILGenerator il, int index); - - // Target object will be loaded on the stack - void Compile(MethodEmitter emitter); + // `target` is the object under test, already cast to the conditional's type (so it is + // null when the cast failed -- TypeCondition, always first, is what rejects that). + Expression Build(ParameterExpression target); } public sealed class TypeCondition : ICondition { public static TypeCondition Default = new(); - void ICondition.Construct(TypeBuilder typeBuilder, ILGenerator il, int index) - { - } - - void ICondition.Compile(MethodEmitter emitter) - { - // The object was safely cast to be the conditionals type - // If it's null, then the type cast didn't work... - - emitter.LoadNull(); - emitter.Compare(OpCodes.Ceq); - emitter.LogicalNot(); - } - } - - public sealed class PropertyValue - { - public PropertyValue(Type type, object value) - { - Type = type; - Value = value; - } - - public Type Type { get; } - - public object Value { get; private set; } - - public FieldInfo Field { get; private set; } - - public bool HasField => Field != null; - - public void Load(MethodEmitter method) - { - if (Field != null) - { - method.LoadArgument(0); - method.LoadField(Field); - } - else if (Value == null) - { - method.LoadNull(Type); - } - else - { - if (Value is int i) - { - method.Load(i); - } - else if (Value is long l) - { - method.Load(l); - } - else if (Value is float f) - { - method.Load(f); - } - else if (Value is double d) - { - method.Load(d); - } - else if (Value is char c) - { - method.Load(c); - } - else if (Value is bool b) - { - method.Load(b); - } - else if (Value is string s) - { - method.Load(s); - } - else if (Value is Enum e) - { - method.Load(e); - } - else - { - throw new InvalidOperationException("Unrecognized comparison value."); - } - } - } - - public void Acquire(TypeBuilder typeBuilder, ILGenerator il, string fieldName) - { - if (Value is not string toParse) - { - return; - } - - if (!Type.IsValueType && toParse == "null") - { - Value = null; - } - else if (Type == typeof(string)) - { - if (toParse == @"@""null""") - { - toParse = "null"; - } - - Value = toParse; - } - else if (Type.IsEnum) - { - Value = Enum.Parse(Type, toParse, true); - } - else if (Type == typeof(bool)) - { - Value = bool.Parse(toParse); - } - else - { - MethodInfo parseMethod; - object[] parseArgs; - - var parseNumber = Type.GetMethod( - "Parse", - BindingFlags.Public | BindingFlags.Static, - null, - Types.ParseStringNumericParamTypes, - null - ); - - if (parseNumber != null) - { - var style = NumberStyles.Integer; - - if (toParse.InsensitiveStartsWith("0x")) - { - style = NumberStyles.HexNumber; - toParse = toParse[2..]; - } - - parseMethod = parseNumber; - parseArgs = new object[] { toParse, style }; - } - else - { - var parseGeneral = Type.GetMethod( - "Parse", - BindingFlags.Public | BindingFlags.Static, - null, - Types.ParseStringParamTypes, - null - ); - - parseMethod = parseGeneral; - parseArgs = new object[] { toParse, null }; - } - - if (parseMethod != null) - { - Value = parseMethod.Invoke(null, parseArgs); - - if (!Type.IsPrimitive) - { - Field = typeBuilder.DefineField( - fieldName, - Type, - FieldAttributes.Private | FieldAttributes.InitOnly - ); - - il.Emit(OpCodes.Ldarg_0); - - il.Emit(OpCodes.Ldstr, toParse); - - if (parseArgs.Length == 2) // dirty evil hack :-( - { - if (parseArgs[1]?.GetType() == typeof(NumberStyles)) - { - il.Emit(OpCodes.Ldc_I4, (int)parseArgs[1]); - } - else - { - // IFormatProvider for `IParsable.Parse()` method. - il.Emit(OpCodes.Ldnull); - } - } - - il.Emit(OpCodes.Call, parseMethod); - il.Emit(OpCodes.Stfld, Field); - } - } - else - { - throw new InvalidOperationException( - $"Unable to convert string \"{Value}\" into type '{Type}'." - ); - } - } - } + Expression ICondition.Build(ParameterExpression target) => + Expression.ReferenceNotEqual(target, Expression.Constant(null, target.Type)); } public abstract class PropertyCondition : ICondition @@ -228,9 +35,22 @@ namespace Server.Commands.Generic m_Not = not; } - public abstract void Construct(TypeBuilder typeBuilder, ILGenerator il, int index); + public abstract Expression Build(ParameterExpression target); - public abstract void Compile(MethodEmitter emitter); + // A binding like Message.Number dereferences Message first, and Message is null on most + // objects a sweep walks. That is "no match" rather than a crash -- and it stays "no match" + // under negation, so the guard wraps the test after `not` has been applied to it. + protected Expression Guarded(ParameterExpression target, Func test) => + PropertyExpressions.Chain( + target, + m_Property, + value => + { + var result = test(value); + return m_Not ? Expression.Not(result) : result; + }, + Expression.Constant(false) + ); } public enum StringOperator @@ -247,125 +67,62 @@ namespace Server.Commands.Generic { private readonly bool m_IgnoreCase; private readonly StringOperator m_Operator; - private readonly PropertyValue m_Value; + private readonly object m_Value; public StringCondition(Property property, bool not, StringOperator op, object value, bool ignoreCase) : base(property, not) { m_Operator = op; - m_Value = new PropertyValue(property.Type, value); - + m_Value = value; m_IgnoreCase = ignoreCase; } - public override void Construct(TypeBuilder typeBuilder, ILGenerator il, int index) + public override Expression Build(ParameterExpression target) { - m_Value.Acquire(typeBuilder, il, $"v{index}"); - } - - public override void Compile(MethodEmitter emitter) - { - var inverse = false; - - var type = m_IgnoreCase ? typeof(InsensitiveStringHelpers) : typeof(OrdinalStringHelpers); - string methodName; - - switch (m_Operator) + if (m_Property.Type != typeof(string)) { - case StringOperator.NotEqual: - { - inverse = true; - goto case StringOperator.Equal; - } - case StringOperator.Equal: - { - methodName = m_IgnoreCase ? "InsensitiveEquals" : "EqualsOrdinal"; - break; - } - - case StringOperator.Contains: - { - methodName = m_IgnoreCase ? "InsensitiveContains" : "ContainsOrdinal"; - break; - } - - case StringOperator.StartsWith: - { - methodName = m_IgnoreCase ? "InsensitiveStartsWith" : "StartsWithOrdinal"; - break; - } - - case StringOperator.EndsWith: - { - methodName = m_IgnoreCase ? "InsensitiveEndsWith" : "EndsWithOrdinal"; - break; - } - - default: - { - throw new InvalidOperationException("Invalid string comparison operator."); - } + throw new InvalidOperationException("String operators require a string property."); } - if (m_Operator is StringOperator.Equal or StringOperator.NotEqual) + var inverse = m_Operator == StringOperator.NotEqual; + + var methodName = m_Operator switch { - emitter.BeginCall( - type.GetMethod( - methodName, - BindingFlags.Public | BindingFlags.Static, - null, - [typeof(string), typeof(string)], - null - ) - ); + StringOperator.Equal or StringOperator.NotEqual => m_IgnoreCase ? "InsensitiveEquals" : "EqualsOrdinal", + StringOperator.Contains => m_IgnoreCase ? "InsensitiveContains" : "ContainsOrdinal", + StringOperator.StartsWith => m_IgnoreCase ? "InsensitiveStartsWith" : "StartsWithOrdinal", + StringOperator.EndsWith => m_IgnoreCase ? "InsensitiveEndsWith" : "EndsWithOrdinal", + _ => throw new InvalidOperationException("Invalid string comparison operator.") + }; - emitter.Chain(m_Property); - m_Value.Load(emitter); + var helper = (m_IgnoreCase ? typeof(InsensitiveStringHelpers) : typeof(OrdinalStringHelpers)).GetMethod( + methodName, + BindingFlags.Public | BindingFlags.Static, + null, + [typeof(string), typeof(string)], + null + ); - emitter.FinishCall(); - } - else - { - var notNull = emitter.CreateLabel(); - var moveOn = emitter.CreateLabel(); + var constant = PropertyExpressions.Constant(typeof(string), m_Value); - var temp = emitter.AcquireTemp(m_Property.Type); + return Guarded( + target, + value => + { + Expression test = Expression.Call(helper, value, constant); - emitter.Chain(m_Property); + // The equality helpers handle a null of their own; the rest need the guard. + if (m_Operator is not (StringOperator.Equal or StringOperator.NotEqual)) + { + test = Expression.AndAlso( + Expression.ReferenceNotEqual(value, Expression.Constant(null, typeof(string))), + test + ); + } - emitter.StoreLocal(temp); - emitter.LoadLocal(temp); - - emitter.BranchIfTrue(notNull); - - emitter.Load(false); - emitter.Pop(); - emitter.Branch(moveOn); - - emitter.MarkLabel(notNull); - emitter.LoadLocal(temp); - - emitter.BeginCall( - type.GetMethod( - methodName, - BindingFlags.Public | BindingFlags.Static, - null, - [typeof(string), typeof(string)], - null - ) - ); - - m_Value.Load(emitter); - - emitter.FinishCall(); - - emitter.MarkLabel(moveOn); - } - - if (m_Not != inverse) - { - emitter.LogicalNot(); - } + return inverse ? Expression.Not(test) : test; + } + ); } } @@ -382,219 +139,110 @@ namespace Server.Commands.Generic public sealed class ComparisonCondition : PropertyCondition { private readonly ComparisonOperator m_Operator; - private readonly PropertyValue m_Value; + private readonly object m_Value; public ComparisonCondition(Property property, bool not, ComparisonOperator op, object value) : base(property, not) { m_Operator = op; - m_Value = new PropertyValue(property.Type, value); + m_Value = value; } - public override void Construct(TypeBuilder typeBuilder, ILGenerator il, int index) + public override Expression Build(ParameterExpression target) { - m_Value.Acquire(typeBuilder, il, $"v{index}"); - } + var constant = PropertyExpressions.Constant(m_Property.Type, m_Value); - public override void Compile(MethodEmitter emitter) - { - emitter.Chain(m_Property); - - var inverse = false; - - var couldCompare = - emitter.CompareTo(1, () => { m_Value.Load(emitter); }); - - if (couldCompare) - { - emitter.Load(0); - - switch (m_Operator) + return Guarded( + target, + value => { - case ComparisonOperator.Equal: - { - emitter.Compare(OpCodes.Ceq); - break; - } + if (PropertyExpressions.TryRelational(value, constant, m_Operator, out var test)) + { + return test; + } - case ComparisonOperator.NotEqual: - { - emitter.Compare(OpCodes.Ceq); - inverse = true; - break; - } - - case ComparisonOperator.Greater: - { - emitter.Compare(OpCodes.Cgt); - break; - } - - case ComparisonOperator.GreaterEqual: - { - emitter.Compare(OpCodes.Clt); - inverse = true; - break; - } - - case ComparisonOperator.Lesser: - { - emitter.Compare(OpCodes.Clt); - break; - } - - case ComparisonOperator.LesserEqual: - { - emitter.Compare(OpCodes.Cgt); - inverse = true; - break; - } - - default: - { - throw new InvalidOperationException("Invalid comparison operator."); - } + // This type is -not- comparable. We can only support == and != operations. + return m_Operator switch + { + ComparisonOperator.Equal => PropertyExpressions.ValueEquals(value, constant), + ComparisonOperator.NotEqual => Expression.Not(PropertyExpressions.ValueEquals(value, constant)), + ComparisonOperator.Greater or ComparisonOperator.GreaterEqual + or ComparisonOperator.Lesser or ComparisonOperator.LesserEqual => + throw new InvalidOperationException("Property does not support relational comparisons."), + _ => throw new InvalidOperationException("Invalid operator.") + }; } - } - else - { - // This type is -not- comparable - // We can only support == and != operations - - m_Value.Load(emitter); - - switch (m_Operator) - { - case ComparisonOperator.Equal: - { - emitter.Compare(OpCodes.Ceq); - break; - } - - case ComparisonOperator.NotEqual: - { - emitter.Compare(OpCodes.Ceq); - inverse = true; - break; - } - - case ComparisonOperator.Greater: - case ComparisonOperator.GreaterEqual: - case ComparisonOperator.Lesser: - case ComparisonOperator.LesserEqual: - { - throw new InvalidOperationException("Property does not support relational comparisons."); - } - - default: - { - throw new InvalidOperationException("Invalid operator."); - } - } - } - - if (m_Not != inverse) - { - emitter.LogicalNot(); - } + ); } } public static class ConditionalCompiler { - public static IConditional Compile(AssemblyEmitter assembly, Type objectType, ICondition[] conditions, int index) + private sealed class CompiledConditional : IConditional { - var typeBuilder = assembly.DefineType( - $"__conditional{index}", - TypeAttributes.Public, - typeof(object) - ); - { - var ctor = typeBuilder.DefineConstructor( - MethodAttributes.Public, - CallingConventions.Standard, - Type.EmptyTypes - ); + private readonly Func _verify; - var il = ctor.GetILGenerator(); + public CompiledConditional(Func verify) => _verify = verify; - // : base() - il.Emit(OpCodes.Ldarg_0); - il.Emit(OpCodes.Call, typeof(object).GetConstructor(Type.EmptyTypes)); + public bool Verify(object obj) => _verify(obj); + } - for (var i = 0; i < conditions.Length; ++i) + /// + /// Compiles a conjunction of conditions over into a single + /// delegate. The conditions short-circuit left to right, so + /// comes first and the rest can assume a non-null, correctly typed target. + /// + public static IConditional Compile(Type objectType, ICondition[] conditions) => + new CompiledConditional(Build(objectType, conditions).Compile()); + + public static Expression> Build(Type objectType, ICondition[] conditions) => + Lambda(objectType, target => Conjunction(target, conditions)); + + /// + /// A disjunction of conjunctions -- (a and b) or (c and d) -- as one lambda, for + /// callers that would otherwise compile every group separately and loop over them. + /// + public static Expression> Build(Type objectType, ICondition[][] groups) => + Lambda( + objectType, + target => { - conditions[i].Construct(typeBuilder, il, i); - } + Expression body = groups.Length > 0 ? Conjunction(target, groups[0]) : Expression.Constant(false); - // return; - il.Emit(OpCodes.Ret); - } - - typeBuilder.AddInterfaceImplementation(typeof(IConditional)); - - MethodBuilder compareMethod; - { - var emitter = new MethodEmitter(typeBuilder); - - emitter.Define( - /* name */ "Verify", - /* attr */ - MethodAttributes.Public | MethodAttributes.Virtual, - /* return */ - typeof(bool), - /* params */ - new[] { typeof(object) } - ); - - var obj = emitter.CreateLocal(objectType); - var eq = emitter.CreateLocal(typeof(bool)); - - emitter.LoadArgument(1); - emitter.CastAs(objectType); - emitter.StoreLocal(obj); - - var done = emitter.CreateLabel(); - - for (var i = 0; i < conditions.Length; ++i) - { - if (i > 0) + for (var i = 1; i < groups.Length; ++i) { - emitter.LoadLocal(eq); - - emitter.BranchIfFalse(done); + body = Expression.OrElse(body, Conjunction(target, groups[i])); } - emitter.LoadLocal(obj); - - conditions[i].Compile(emitter); - - emitter.StoreLocal(eq); + return body; } + ); - emitter.MarkLabel(done); + private static Expression Conjunction(ParameterExpression target, ICondition[] conditions) + { + Expression body = conditions.Length > 0 ? conditions[0].Build(target) : Expression.Constant(true); - emitter.LoadLocal(eq); - - emitter.Return(); - - typeBuilder.DefineMethodOverride( - emitter.Method, - typeof(IConditional).GetMethod( - "Verify", - new[] - { - typeof(object) - } - ) - ); - - compareMethod = emitter.Method; + for (var i = 1; i < conditions.Length; ++i) + { + body = Expression.AndAlso(body, conditions[i].Build(target)); } - var conditionalType = typeBuilder.CreateType(); + return body; + } - return conditionalType.CreateInstance(); + private static Expression> Lambda(Type objectType, Func body) + { + var obj = Expression.Parameter(typeof(object), "obj"); + var target = Expression.Variable(objectType, "target"); + + return Expression.Lambda>( + Expression.Block( + [target], + Expression.Assign(target, Expression.TypeAs(obj, objectType)), + body(target) + ), + obj + ); } } } diff --git a/Projects/UOContent/Commands/Generic/Extensions/Compilers/DistinctCompiler.cs b/Projects/UOContent/Commands/Generic/Extensions/Compilers/DistinctCompiler.cs index 9e96ff2f7..7d80199b7 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/Compilers/DistinctCompiler.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/Compilers/DistinctCompiler.cs @@ -1,252 +1,95 @@ using System; using System.Collections.Generic; -using System.Reflection; -using System.Reflection.Emit; +using System.Linq.Expressions; namespace Server.Commands.Generic { public static class DistinctCompiler { - public static IComparer Compile(AssemblyEmitter assembly, Type objectType, Property[] props) + private sealed class DistinctComparer : IComparer, IEqualityComparer { - var typeBuilder = assembly.DefineType( - "__distinct", - TypeAttributes.Public, - typeof(object) + private readonly Comparison _compare; + private readonly Func _hash; + + public DistinctComparer(Comparison compare, Func hash) + { + _compare = compare; + _hash = hash; + } + + public int Compare(T x, T y) => _compare(x, y); + + public bool Equals(T x, T y) => _compare(x, y) == 0; + + public int GetHashCode(T obj) => _hash(obj); + } + + /// + /// A comparer that treats two objects as the same when every one of + /// reads equal on both. Ordering is the sort compiler's, all + /// ascending, so the result doubles as an . + /// + public static IComparer Compile(Type objectType, Property[] props) + { + var signs = new int[props.Length]; + Array.Fill(signs, 1); + + return new DistinctComparer( + SortCompiler.Build(objectType, props, signs).Compile(), + BuildHash(objectType, props).Compile() ); + } + + // XOR of each property's hash; a null reference hashes to 0 and an int hashes to itself. + public static Expression> BuildHash(Type objectType, Property[] props) + { + var arg = Expression.Parameter(typeof(T), "obj"); + var target = Expression.Variable(objectType, "target"); + + Expression hash = Expression.Constant(0); + + for (var i = 0; i < props.Length; ++i) { - var ctor = typeBuilder.DefineConstructor( - MethodAttributes.Public, - CallingConventions.Standard, - Type.EmptyTypes - ); + var part = HashOf(target, props[i]); - var il = ctor.GetILGenerator(); - - // : base() - il.Emit(OpCodes.Ldarg_0); - il.Emit( - OpCodes.Call, - typeof(T).GetConstructor(Type.EmptyTypes) ?? - throw new Exception($"Could not find empty constructor for type {typeof(T).FullName}") - ); - - // return; - il.Emit(OpCodes.Ret); + hash = i == 0 ? part : Expression.ExclusiveOr(hash, part); } - typeBuilder.AddInterfaceImplementation(typeof(IComparer)); + return Expression.Lambda>( + Expression.Block( + [target], + Expression.Assign(target, Expression.TypeAs(arg, objectType)), + hash + ), + arg + ); + } - MethodBuilder compareMethod; + private static Expression HashOf(Expression target, Property prop) + { + var read = PropertyExpressions.ChainOrDefault(target, prop); + var type = prop.Type; + + if (type == typeof(int)) { - var emitter = new MethodEmitter(typeBuilder); - - emitter.Define( - /* name */ "Compare", - /* attr */ - MethodAttributes.Public | MethodAttributes.Virtual, - /* return */ - typeof(int), - /* params */ - new[] { typeof(T), typeof(T) } - ); - - var a = emitter.CreateLocal(objectType); - var b = emitter.CreateLocal(objectType); - - var v = emitter.CreateLocal(typeof(int)); - - emitter.LoadArgument(1); - emitter.CastAs(objectType); - emitter.StoreLocal(a); - - emitter.LoadArgument(2); - emitter.CastAs(objectType); - emitter.StoreLocal(b); - - emitter.Load(0); - emitter.StoreLocal(v); - - var end = emitter.CreateLabel(); - - for (var i = 0; i < props.Length; ++i) - { - if (i > 0) - { - emitter.LoadLocal(v); - emitter.BranchIfTrue(end); - } - - var prop = props[i]; - - emitter.LoadLocal(a); - emitter.Chain(prop); - - var couldCompare = - emitter.CompareTo( - 1, - () => - { - emitter.LoadLocal(b); - emitter.Chain(prop); - } - ); - - if (!couldCompare) - { - throw new InvalidOperationException("Property is not comparable."); - } - - emitter.StoreLocal(v); - } - - emitter.MarkLabel(end); - - emitter.LoadLocal(v); - emitter.Return(); - - typeBuilder.DefineMethodOverride( - emitter.Method, - typeof(IComparer).GetMethod( - "Compare", - new[] - { - typeof(T), - typeof(T) - } - ) ?? throw new Exception($"No Compare method found for type {typeof(T).FullName}") - ); - - compareMethod = emitter.Method; + return read; } - typeBuilder.AddInterfaceImplementation(typeof(IEqualityComparer)); + var value = Expression.Variable(type, prop.Binding); + var getHashCode = type.GetMethod("GetHashCode", Type.EmptyTypes) ?? typeof(object).GetMethod("GetHashCode", Type.EmptyTypes)!; + + Expression hash = Expression.Call(value, getHashCode); + + if (!type.IsValueType) { - var emitter = new MethodEmitter(typeBuilder); - - emitter.Define( - /* name */ "Equals", - /* attr */ - MethodAttributes.Public | MethodAttributes.Virtual, - /* return */ - typeof(bool), - /* params */ - new[] { typeof(T), typeof(T) } - ); - - emitter.Generator.Emit(OpCodes.Ldarg_0); - emitter.Generator.Emit(OpCodes.Ldarg_1); - emitter.Generator.Emit(OpCodes.Ldarg_2); - - emitter.Generator.Emit(OpCodes.Call, compareMethod); - - emitter.Generator.Emit(OpCodes.Ldc_I4_0); - - emitter.Generator.Emit(OpCodes.Ceq); - - emitter.Generator.Emit(OpCodes.Ret); - - typeBuilder.DefineMethodOverride( - emitter.Method, - typeof(IEqualityComparer).GetMethod( - "Equals", - new[] - { - typeof(T), - typeof(T) - } - ) ?? throw new Exception($"No Equals method found for type {typeof(T).FullName}") + hash = Expression.Condition( + Expression.ReferenceNotEqual(value, Expression.Constant(null, type)), + hash, + Expression.Constant(0) ); } - { - var emitter = new MethodEmitter(typeBuilder); - - emitter.Define( - /* name */ "GetHashCode", - /* attr */ - MethodAttributes.Public | MethodAttributes.Virtual, - /* return */ - typeof(int), - /* params */ - new[] { typeof(T) } - ); - - var obj = emitter.CreateLocal(objectType); - - emitter.LoadArgument(1); - emitter.CastAs(objectType); - emitter.StoreLocal(obj); - - for (var i = 0; i < props.Length; ++i) - { - var prop = props[i]; - - emitter.LoadLocal(obj); - emitter.Chain(prop); - - var active = emitter.Active; - - var getHashCode = active.GetMethod("GetHashCode", Type.EmptyTypes) - ?? typeof(T).GetMethod("GetHashCode", Type.EmptyTypes); - - if (active != typeof(int)) - { - if (!active.IsValueType) - { - var value = emitter.AcquireTemp(active); - - var valueNotNull = emitter.CreateLabel(); - var done = emitter.CreateLabel(); - - emitter.StoreLocal(value); - emitter.LoadLocal(value); - - emitter.BranchIfTrue(valueNotNull); - - emitter.Load(0); - emitter.Pop(typeof(int)); - - emitter.Branch(done); - - emitter.MarkLabel(valueNotNull); - - emitter.LoadLocal(value); - emitter.Call(getHashCode); - - emitter.ReleaseTemp(value); - - emitter.MarkLabel(done); - } - else - { - emitter.Call(getHashCode); - } - } - - if (i > 0) - { - emitter.Xor(); - } - } - - emitter.Return(); - - typeBuilder.DefineMethodOverride( - emitter.Method, - typeof(IEqualityComparer).GetMethod( - "GetHashCode", - new[] - { - typeof(T) - } - ) ?? throw new Exception($"No GetHashCode method found for type {typeof(T).FullName}") - ); - } - - var comparerType = typeBuilder.CreateType(); - - return comparerType.CreateInstance>(); + return Expression.Block([value], Expression.Assign(value, read), hash); } } } diff --git a/Projects/UOContent/Commands/Generic/Extensions/Compilers/PropertyExpressions.cs b/Projects/UOContent/Commands/Generic/Extensions/Compilers/PropertyExpressions.cs new file mode 100644 index 000000000..68f30447c --- /dev/null +++ b/Projects/UOContent/Commands/Generic/Extensions/Compilers/PropertyExpressions.cs @@ -0,0 +1,407 @@ +using System; +using System.Globalization; +using System.Linq.Expressions; +using System.Reflection; + +namespace Server.Commands.Generic; + +/// +/// Expression-tree fragments over a bound chain, shared by the +/// conditional, sort and distinct compilers. Everything here builds an ; +/// the compilers assemble those into a lambda and hand Compile() the codegen. +/// +public static class PropertyExpressions +{ + private static readonly MethodInfo _objectEquals = typeof(object).GetMethod( + nameof(object.Equals), + BindingFlags.Public | BindingFlags.Static, + [typeof(object), typeof(object)] + )!; + + /// + /// Walks a property binding. A binding of more than one property (Message.Number) + /// dereferences each link in turn, and any link but the last can legitimately be null -- an + /// unset TextDefinition, an unparented item. Each reference-typed intermediate link is stored + /// once and null-checked; a null there yields in place of + /// whatever would have built from the final link. + /// + public static Expression Chain( + Expression target, + Property prop, + Func onValue, + Expression whenUnreadable + ) => ChainFrom(target, prop.Chain, 0, onValue, whenUnreadable); + + private static Expression ChainFrom( + Expression current, + PropertyInfo[] chain, + int index, + Func onValue, + Expression whenUnreadable + ) + { + var link = Expression.Property(current, chain[index]); + + // The last link is the value being tested, so a null there is the caller's business. + if (index == chain.Length - 1) + { + return onValue(link); + } + + if (link.Type.IsValueType) + { + return ChainFrom(link, chain, index + 1, onValue, whenUnreadable); + } + + var local = Expression.Variable(link.Type, chain[index].Name); + + return Expression.Block( + [local], + Expression.Assign(local, link), + Expression.Condition( + Expression.ReferenceNotEqual(local, Expression.Constant(null, local.Type)), + ChainFrom(local, chain, index + 1, onValue, whenUnreadable), + whenUnreadable + ) + ); + } + + /// + /// Walks a binding for a caller that has no way to express "no match" -- ordering and + /// grouping, where the value itself is the answer rather than a yes or no. An unreadable + /// link yields default(T), which is what a null link along the way amounts to; the + /// comparers these feed already handle a null value. + /// + public static Expression ChainOrDefault(Expression target, Property prop) => + Chain(target, prop, static value => value, Expression.Default(prop.Type)); + + /// + /// Equality for the "not comparable" path, which supports only == and !=. Reference equality + /// would miss a type whose equality is by value -- among them -- + /// so this is static object.Equals, which honors the override and is null-safe on + /// either side. Value types box; they only reach here when they have no CompareTo. + /// + public static Expression ValueEquals(Expression a, Expression b) => + Expression.Call(_objectEquals, Box(a), Box(b)); + + private static Expression Box(Expression e) => + e.Type == typeof(object) ? e : Expression.Convert(e, typeof(object)); + + /// + /// A boolean test of against . Integral primitives + /// and enums compare with the operator itself -- on the unsigned types as unsigned; nothing + /// here widens to a signed type. Everything else goes through and + /// tests the sign of the result. Nullable<T> is lifted either way, as C# lifts it: + /// two nulls are equal, a null and a value are unequal, and a null satisfies no relation. + /// (A null reference keeps the ordering gives it.) False + /// when the type has no CompareTo at all, in which case only equality is meaningful. + /// + public static bool TryRelational(Expression a, Expression b, ComparisonOperator op, out Expression test) + { + var type = a.Type; + var underlying = Nullable.GetUnderlyingType(type); + var nonNullable = underlying ?? type; + + if (underlying != null && !underlying.IsEnum && !IsIntegral(underlying)) + { + return TryLiftedRelational(a, b, op, out test); + } + + if (nonNullable.IsEnum) + { + // Equal/NotEqual are defined on enums; the relational operators are not, so those + // read the underlying integer. Convert lifts over Nullable on its own. + if (op is not (ComparisonOperator.Equal or ComparisonOperator.NotEqual)) + { + var integer = Enum.GetUnderlyingType(nonNullable); + + if (underlying != null) + { + integer = typeof(Nullable<>).MakeGenericType(integer); + } + + a = Expression.Convert(a, integer); + b = Expression.Convert(b, integer); + } + + test = Relational(a, b, op); + return true; + } + + if (IsIntegral(nonNullable)) + { + test = Relational(a, b, op); + return true; + } + + if (!TryCompare(a, b, 1, out var comparison)) + { + test = null; + return false; + } + + test = Relational(comparison, Expression.Constant(0), op); + return true; + } + + // Nullable over a type that compares through CompareTo: the values compare when both are + // present, and the HasValue flags decide otherwise, the way the language lifts an operator. + private static bool TryLiftedRelational(Expression a, Expression b, ComparisonOperator op, out Expression test) + { + var left = Expression.Variable(a.Type, "left"); + var right = Expression.Variable(a.Type, "right"); + + var couldCompare = TryCompareValues( + Expression.Property(left, "Value"), + Expression.Property(right, "Value"), + 1, + out var comparison + ); + + if (!couldCompare) + { + test = null; + return false; + } + + var leftHasValue = Expression.Property(left, "HasValue"); + var rightHasValue = Expression.Property(right, "HasValue"); + var both = Expression.AndAlso(leftHasValue, rightHasValue); + var relation = Relational(comparison, Expression.Constant(0), op); + + Expression lifted = op switch + { + ComparisonOperator.Equal => Expression.Condition(both, relation, Expression.Equal(leftHasValue, rightHasValue)), + ComparisonOperator.NotEqual => Expression.Condition(both, relation, Expression.NotEqual(leftHasValue, rightHasValue)), + _ => Expression.AndAlso(both, relation) + }; + + test = Expression.Block( + [left, right], + Expression.Assign(left, a), + Expression.Assign(right, b), + lifted + ); + + return true; + } + + private static Expression Relational(Expression a, Expression b, ComparisonOperator op) => + op switch + { + ComparisonOperator.Equal => Expression.Equal(a, b), + ComparisonOperator.NotEqual => Expression.NotEqual(a, b), + ComparisonOperator.Greater => Expression.GreaterThan(a, b), + ComparisonOperator.GreaterEqual => Expression.GreaterThanOrEqual(a, b), + ComparisonOperator.Lesser => Expression.LessThan(a, b), + ComparisonOperator.LesserEqual => Expression.LessThanOrEqual(a, b), + _ => throw new InvalidOperationException("Invalid comparison operator.") + }; + + private static bool IsIntegral(Type type) => + type == typeof(int) || type == typeof(long) || type == typeof(uint) || type == typeof(ulong) + || type == typeof(short) || type == typeof(ushort) || type == typeof(byte) || type == typeof(sbyte); + + /// + /// An int-valued comparison of against + /// with CompareTo semantics, multiplied by . A null on either + /// side of a reference or nullable type is handled here rather than in the callee: + /// null.CompareTo(null) = 0, real.CompareTo(null) = -sign, + /// null.CompareTo(real) = +sign. False when the type has no CompareTo. + /// + public static bool TryCompare(Expression a, Expression b, int sign, out Expression comparison) + { + var type = a.Type; + + // Both sides are read more than once below; pin them so a chained binding is walked once. + var left = Expression.Variable(type, "left"); + var right = Expression.Variable(type, "right"); + + if (!TryCompareValues(left, right, sign, out var body)) + { + comparison = null; + return false; + } + + comparison = Expression.Block( + [left, right], + Expression.Assign(left, a), + Expression.Assign(right, b), + body + ); + + return true; + } + + private static bool TryCompareValues(Expression a, Expression b, int sign, out Expression comparison) + { + var type = a.Type; + var underlying = Nullable.GetUnderlyingType(type); + + if (underlying != null) + { + if (!TryCompareValues(Expression.Property(a, "Value"), Expression.Property(b, "Value"), sign, out var inner)) + { + comparison = null; + return false; + } + + comparison = NullAware(Expression.Property(a, "HasValue"), Expression.Property(b, "HasValue"), inner, sign); + return true; + } + + if (type.IsEnum) + { + var integer = Enum.GetUnderlyingType(type); + + return TryCompareValues(Expression.Convert(a, integer), Expression.Convert(b, integer), sign, out comparison); + } + + var compareTo = FindCompareTo(type); + + if (compareTo == null) + { + comparison = null; + return false; + } + + var parameterType = compareTo.GetParameters()[0].ParameterType; + var argument = parameterType == type ? b : Expression.Convert(b, parameterType); + + Expression call = Expression.Call(a, compareTo, argument); + + if (sign == -1) + { + call = Expression.Negate(call); + } + + if (type.IsValueType) + { + comparison = call; + return true; + } + + var nil = Expression.Constant(null, type); + + comparison = NullAware(Expression.ReferenceNotEqual(a, nil), Expression.ReferenceNotEqual(b, nil), call, sign); + return true; + } + + private static Expression NullAware(Expression aHasValue, Expression bHasValue, Expression compare, int sign) => + Expression.Condition( + aHasValue, + Expression.Condition(bHasValue, compare, Expression.Constant(-sign)), + Expression.Condition(bHasValue, Expression.Constant(sign), Expression.Constant(0)) + ); + + private static MethodInfo FindCompareTo(Type type) + { + var compareTo = type.GetMethod("CompareTo", [type]); + + if (compareTo != null) + { + return compareTo; + } + + /* There's a scenario where we might be trying to use CompareTo on an interface + * which, while it doesn't explicitly implement CompareTo itself, is said to + * extend IComparable indirectly. The implementation is implicitly passed off + * to implementers, so the interface's own GetMethod("CompareTo") returns null. + */ + var ifaces = type.FindInterfaces( + static (iface, _) => iface.IsGenericType && iface.GetGenericTypeDefinition() == typeof(IComparable<>), + null + ); + + for (var i = 0; i < ifaces.Length; ++i) + { + if (ifaces[i].GetGenericArguments()[0].IsAssignableFrom(type)) + { + return ifaces[i].GetMethod("CompareTo", [type]); + } + } + + return typeof(IComparable).IsAssignableFrom(type) + ? typeof(IComparable).GetMethod("CompareTo", [typeof(object)]) + : null; + } + + /// + /// The right-hand side of a condition as a typed constant. A string is parsed the way the + /// props gump would parse it: null for a reference or nullable type, @"null" + /// for the literal string, names for enums, hex with a 0x prefix for the numerics, + /// and the type's own static Parse for everything else. + /// + public static ConstantExpression Constant(Type type, object value) + { + if (value is string text) + { + value = Parse(type, text); + } + + return Expression.Constant(value, type); + } + + private static object Parse(Type type, string text) + { + var underlying = Nullable.GetUnderlyingType(type); + + if (text == "null" && (underlying != null || !type.IsValueType)) + { + return null; + } + + var target = underlying ?? type; + + if (target == typeof(string)) + { + return text == @"@""null""" ? "null" : text; + } + + if (target.IsEnum) + { + return Enum.Parse(target, text, true); + } + + if (target == typeof(bool)) + { + return bool.Parse(text); + } + + var parseNumber = target.GetMethod( + "Parse", + BindingFlags.Public | BindingFlags.Static, + null, + Types.ParseStringNumericParamTypes, + null + ); + + if (parseNumber != null) + { + var style = NumberStyles.Integer; + + if (text.InsensitiveStartsWith("0x")) + { + style = NumberStyles.HexNumber; + text = text[2..]; + } + + return parseNumber.Invoke(null, [text, style]); + } + + var parseGeneral = target.GetMethod( + "Parse", + BindingFlags.Public | BindingFlags.Static, + null, + Types.ParseStringParamTypes, + null + ); + + if (parseGeneral != null) + { + return parseGeneral.Invoke(null, [text, null]); + } + + throw new InvalidOperationException($"Unable to convert string \"{text}\" into type '{type}'."); + } +} diff --git a/Projects/UOContent/Commands/Generic/Extensions/Compilers/SortCompiler.cs b/Projects/UOContent/Commands/Generic/Extensions/Compilers/SortCompiler.cs index a597ece98..812a6726a 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/Compilers/SortCompiler.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/Compilers/SortCompiler.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; -using System.Reflection; -using System.Reflection.Emit; +using System.Linq.Expressions; namespace Server.Commands.Generic { @@ -47,120 +46,83 @@ namespace Server.Commands.Generic public static class SortCompiler { - public static IComparer Compile(AssemblyEmitter assembly, Type objectType, OrderInfo[] orders) + public static IComparer Compile(Type objectType, OrderInfo[] orders) { - var typeBuilder = assembly.DefineType( - "__sort", - TypeAttributes.Public, - typeof(T) + var properties = new Property[orders.Length]; + var signs = new int[orders.Length]; + + for (var i = 0; i < orders.Length; ++i) + { + properties[i] = orders[i].Property; + signs[i] = orders[i].Sign; + } + + return Comparer.Create(Build(objectType, properties, signs).Compile()); + } + + /// + /// A over , taken in order: the + /// first property that orders the two objects decides, each multiplied by its sign. Both + /// arguments are cast to first; the bindings are read from + /// that. + /// + public static Expression> Build(Type objectType, Property[] properties, int[] signs) + { + var x = Expression.Parameter(typeof(T), "x"); + var y = Expression.Parameter(typeof(T), "y"); + + var a = Expression.Variable(objectType, "a"); + var b = Expression.Variable(objectType, "b"); + + return Expression.Lambda>( + Expression.Block( + [a, b], + Expression.Assign(a, Expression.TypeAs(x, objectType)), + Expression.Assign(b, Expression.TypeAs(y, objectType)), + Ordered(a, b, properties, signs, 0) + ), + x, + y ); + } + + private static Expression Ordered(Expression a, Expression b, Property[] properties, int[] signs, int index) + { + if (index >= properties.Length) { - var ctor = typeBuilder.DefineConstructor( - MethodAttributes.Public, - CallingConventions.Standard, - Type.EmptyTypes - ); - - var il = ctor.GetILGenerator(); - - // : base() - il.Emit(OpCodes.Ldarg_0); - il.Emit( - OpCodes.Call, - typeof(T).GetConstructor(Type.EmptyTypes) ?? - throw new Exception($"Could not find empty constructor for type {typeof(T).FullName}") - ); - - // return; - il.Emit(OpCodes.Ret); + return Expression.Constant(0); } - typeBuilder.AddInterfaceImplementation(typeof(IComparer)); + var prop = properties[index]; + + var couldCompare = PropertyExpressions.TryCompare( + PropertyExpressions.ChainOrDefault(a, prop), + PropertyExpressions.ChainOrDefault(b, prop), + signs[index], + out var comparison + ); + + if (!couldCompare) { - var emitter = new MethodEmitter(typeBuilder); - - emitter.Define( - /* name */ "Compare", - /* attr */ - MethodAttributes.Public | MethodAttributes.Virtual, - /* return */ - typeof(int), - /* params */ - new[] { typeof(T), typeof(T) } - ); - - var a = emitter.CreateLocal(objectType); - var b = emitter.CreateLocal(objectType); - - var v = emitter.CreateLocal(typeof(int)); - - emitter.LoadArgument(1); - emitter.CastAs(objectType); - emitter.StoreLocal(a); - - emitter.LoadArgument(2); - emitter.CastAs(objectType); - emitter.StoreLocal(b); - - emitter.Load(0); - emitter.StoreLocal(v); - - var end = emitter.CreateLabel(); - - for (var i = 0; i < orders.Length; ++i) - { - if (i > 0) - { - emitter.LoadLocal(v); - emitter.BranchIfTrue(end); - } - - var orderInfo = orders[i]; - - var prop = orderInfo.Property; - var sign = orderInfo.Sign; - - emitter.LoadLocal(a); - emitter.Chain(prop); - - var couldCompare = - emitter.CompareTo( - sign, - () => - { - emitter.LoadLocal(b); - emitter.Chain(prop); - } - ); - - if (!couldCompare) - { - throw new InvalidOperationException("Property is not comparable."); - } - - emitter.StoreLocal(v); - } - - emitter.MarkLabel(end); - - emitter.LoadLocal(v); - emitter.Return(); - - typeBuilder.DefineMethodOverride( - emitter.Method, - typeof(IComparer).GetMethod( - "Compare", - new[] - { - typeof(T), - typeof(T) - } - ) ?? throw new Exception($"No Compare method found for type {typeof(T).FullName}") - ); + throw new InvalidOperationException("Property is not comparable."); } - var comparerType = typeBuilder.CreateType(); - return comparerType.CreateInstance>(); + if (index == properties.Length - 1) + { + return comparison; + } + + var v = Expression.Variable(typeof(int), "v"); + + return Expression.Block( + [v], + Expression.Assign(v, comparison), + Expression.Condition( + Expression.NotEqual(v, Expression.Constant(0)), + v, + Ordered(a, b, properties, signs, index + 1) + ) + ); } } } diff --git a/Projects/UOContent/Commands/Generic/Extensions/DistinctExtension.cs b/Projects/UOContent/Commands/Generic/Extensions/DistinctExtension.cs index 2ed85ec1b..bbcb4215a 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/DistinctExtension.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/DistinctExtension.cs @@ -21,7 +21,7 @@ namespace Server.Commands.Generic ExtensionInfo.Register(ExtInfo); } - public override void Optimize(Mobile from, Type baseType, ref AssemblyEmitter assembly) + public override void Optimize(Mobile from, Type baseType) { if (baseType == null) { @@ -34,9 +34,7 @@ namespace Server.Commands.Generic prop.CheckAccess(from); } - assembly ??= new AssemblyEmitter("__dynamic"); - - m_Comparer = DistinctCompiler.Compile(assembly, baseType, m_Properties.ToArray()); + m_Comparer = DistinctCompiler.Compile(baseType, m_Properties.ToArray()); } public override void Parse(Mobile from, string[] arguments, int offset, int size) diff --git a/Projects/UOContent/Commands/Generic/Extensions/SortExtension.cs b/Projects/UOContent/Commands/Generic/Extensions/SortExtension.cs index c81f8f160..adfbcfae8 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/SortExtension.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/SortExtension.cs @@ -20,7 +20,7 @@ namespace Server.Commands.Generic ExtensionInfo.Register(ExtInfo); } - public override void Optimize(Mobile from, Type baseType, ref AssemblyEmitter assembly) + public override void Optimize(Mobile from, Type baseType) { if (baseType == null) { @@ -33,9 +33,7 @@ namespace Server.Commands.Generic order.Property.CheckAccess(from); } - assembly ??= new AssemblyEmitter("__dynamic"); - - m_Comparer = SortCompiler.Compile(assembly, baseType, m_Orders.ToArray()); + m_Comparer = SortCompiler.Compile(baseType, m_Orders.ToArray()); } public override void Parse(Mobile from, string[] arguments, int offset, int size) diff --git a/Projects/UOContent/Commands/Generic/Extensions/WhereExtension.cs b/Projects/UOContent/Commands/Generic/Extensions/WhereExtension.cs index 47d8cd9a2..78f206483 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/WhereExtension.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/WhereExtension.cs @@ -15,14 +15,14 @@ namespace Server.Commands.Generic ExtensionInfo.Register(ExtInfo); } - public override void Optimize(Mobile from, Type baseType, ref AssemblyEmitter assembly) + public override void Optimize(Mobile from, Type baseType) { if (baseType == null) { throw new InvalidOperationException("Insanity."); } - Conditional.Compile(ref assembly); + Conditional.Compile(); } public override void Parse(Mobile from, string[] arguments, int offset, int size) diff --git a/Projects/UOContent/Commands/Generic/Implementors/ObjectConditional.cs b/Projects/UOContent/Commands/Generic/Implementors/ObjectConditional.cs index bbd64e822..750df728d 100644 --- a/Projects/UOContent/Commands/Generic/Implementors/ObjectConditional.cs +++ b/Projects/UOContent/Commands/Generic/Implementors/ObjectConditional.cs @@ -27,15 +27,13 @@ namespace Server.Commands.Generic public bool HasCompiled => m_Conditionals != null; - public void Compile(ref AssemblyEmitter emitter) + public void Compile() { - emitter ??= new AssemblyEmitter("__dynamic"); - m_Conditionals = new IConditional[m_Conditions.Length]; for (var i = 0; i < m_Conditionals.Length; ++i) { - m_Conditionals[i] = ConditionalCompiler.Compile(emitter, Type, m_Conditions[i], i); + m_Conditionals[i] = ConditionalCompiler.Compile(Type, m_Conditions[i]); } } @@ -48,9 +46,7 @@ namespace Server.Commands.Generic if (!HasCompiled) { - AssemblyEmitter emitter = null; - - Compile(ref emitter); + Compile(); } for (var i = 0; i < m_Conditionals.Length; ++i) diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchConditions.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchConditions.cs new file mode 100644 index 000000000..5eb42ac9e --- /dev/null +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchConditions.cs @@ -0,0 +1,432 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; +using Server.Commands.Generic; + +namespace Server.Engines.AdvancedSearch; + +/// +/// Turns a property-test string from the Advanced Search gump into a compiled predicate. The +/// grammar is the gump's own -- ~ negates a leaf, @ is AND, | is OR and binds +/// looser, and the string operators double up (> is "starts with" on a string) -- but +/// each leaf becomes the same a where clause compiles, so there is +/// one comparison engine. A leaf that cannot be resolved or parsed is simply "no match". +/// +/// +/// Runs on the search workers, off the game loop: binding is reflection, compiling is +/// Expression.Compile, and neither touches game state. The one exception is a value that +/// names an entity by serial, which resolves through the world -- +/// the same read the previous per-entity evaluator made, now made once per type instead. +/// +public static class AdvancedSearchConditions +{ + // Runtime type -> its public readable instance properties, for the case-insensitive name scan. + private static readonly ConcurrentDictionary _properties = new(); + + private static readonly Func _never = static _ => false; + + /// + /// Per-search memo shared by every worker. Predicates are keyed twice: by the runtime type + /// seen, and by the type the predicate was actually compiled for -- the most derived type + /// that declares one of the properties -- so every subclass of Item that does not hide + /// Hue shares one compiled Hue = 5. + /// + public sealed class Cache + { + internal readonly ConcurrentDictionary> ByRuntimeType = new(); + internal readonly ConcurrentDictionary> ByCompiledType = new(); + } + + public static Func GetPredicate(Cache cache, Type runtimeType, string propertyTest) => + cache.ByRuntimeType.GetOrAdd( + runtimeType, + static (type, state) => Build(state.cache, type, state.propertyTest), + (cache, propertyTest) + ); + + /// Compiles without a cache. Test seam and one-off use. + public static Func Compile(Type runtimeType, string propertyTest) => + Build(new Cache(), runtimeType, propertyTest); + + private static Func Build(Cache cache, Type runtimeType, string propertyTest) + { + var groups = Parse(runtimeType, propertyTest, out var compiledType); + + if (groups == null) + { + return _never; + } + + return cache.ByCompiledType.GetOrAdd( + compiledType, + static (type, groups) => + { + try + { + return ConditionalCompiler.Build(type, groups).Compile(); + } + catch (Exception) + { + return _never; + } + }, + groups + ); + } + + // OR of ANDs, which is what splitting on '|' and then on '@' yields. A group with a dead leaf + // is dropped; no live group left means nothing can match, reported as null. + private static ICondition[][] Parse(Type runtimeType, string propertyTest, out Type compiledType) + { + Type mostDerived = null; + + var groups = new List(); + + foreach (var orPart in propertyTest.Split('|')) + { + var group = new List { TypeCondition.Default }; + var alive = true; + + foreach (var andPart in orPart.Split('@')) + { + var leaf = Leaf(runtimeType, andPart, out var declaringType); + + if (leaf == null) + { + alive = false; + break; + } + + group.Add(leaf); + + // Every declaring type is an ancestor of the runtime type (or the type itself), so + // they nest; the predicate is compiled for the most derived one any leaf needs and + // serves every runtime type that resolves the same properties. + if (mostDerived == null || declaringType.IsAssignableTo(mostDerived)) + { + mostDerived = declaringType; + } + } + + if (alive) + { + groups.Add(group.ToArray()); + } + } + + compiledType = mostDerived ?? runtimeType; + + return groups.Count > 0 ? groups.ToArray() : null; + } + + private static ICondition Leaf(Type runtimeType, ReadOnlySpan expression, out Type declaringType) + { + declaringType = runtimeType; + expression = expression.Trim(); + + if (expression.Length == 0) + { + return null; + } + + var negate = false; + + if (expression[0] == '~') + { + negate = true; + expression = expression[1..]; + } + + var operatorSpan = AdvancedSearchUtilities.FindOperatorIndex(expression, out var operatorIndex); + + if (operatorSpan.Length == 0) + { + return null; + } + + var propertyName = expression[..operatorIndex].Trim(); + var valuePart = expression[(operatorIndex + operatorSpan.Length)..].Trim(); + + if (valuePart.Length == 0) + { + return null; + } + + var chain = Resolve(runtimeType, propertyName); + + if (chain == null) + { + return null; + } + + declaringType = chain[0].DeclaringType!; + + var property = new Property(chain); + var type = property.Type; + var op = operatorSpan.ToString(); + var value = valuePart.ToString(); + + ICondition condition; + + if (type == typeof(string)) + { + condition = StringLeaf(property, negate, op, value); + } + else if (type == typeof(double) || type == typeof(float)) + { + condition = EpsilonLeaf(property, negate, op, value); + } + else + { + condition = ComparisonLeaf(property, negate, op, value); + } + + return condition != null && Probe(condition, runtimeType) ? condition : null; + } + + // A leaf the compiler rejects -- a relational operator on a type with no CompareTo -- is + // "no match" for that leaf, not an error for the whole search. + private static bool Probe(ICondition condition, Type runtimeType) + { + try + { + condition.Build(Expression.Parameter(runtimeType, "probe")); + return true; + } + catch (Exception) + { + return false; + } + } + + private static ICondition StringLeaf(Property property, bool negate, string op, string value) + { + var (stringOp, ignoreCase) = op switch + { + "=" or "==" => (StringOperator.Equal, false), + "!" or "!=" => (StringOperator.NotEqual, false), + ">" => (StringOperator.StartsWith, false), + "<" => (StringOperator.EndsWith, false), + "~" => (StringOperator.Contains, false), + "~>" => (StringOperator.StartsWith, true), + "~<" => (StringOperator.EndsWith, true), + "~~" => (StringOperator.Contains, true), + "~=" => (StringOperator.Equal, true), + "~!" => (StringOperator.NotEqual, true), + _ => ((StringOperator?)null, false) + }; + + if (stringOp == null) + { + return null; + } + + // `null` is the null string for equality, as it is in a where clause; for the substring + // operators, where a null needle means nothing, it is the four-letter word. + if (value == "null" && stringOp is not (StringOperator.Equal or StringOperator.NotEqual)) + { + value = @"@""null"""; + } + + return new StringCondition(property, negate, stringOp.Value, value, ignoreCase); + } + + private static ICondition EpsilonLeaf(Property property, bool negate, string op, string value) + { + var comparison = MapOperator(op); + + if (comparison == null) + { + return null; + } + + // A float property parses its value as a float first, so the widened constant carries the + // same rounding the property's own value does. + double parsed; + + if (property.Type == typeof(float)) + { + if (!float.TryParse(value, null, out var f)) + { + return null; + } + + parsed = f; + } + else if (!double.TryParse(value, null, out parsed)) + { + return null; + } + + return new EpsilonCondition(property, negate, comparison.Value, parsed, AdvancedSearchUtilities.CalculateEpsilon(value)); + } + + private static ICondition ComparisonLeaf(Property property, bool negate, string op, string value) + { + var comparison = MapOperator(op); + + if (comparison == null) + { + return null; + } + + var type = property.Type; + var underlying = Nullable.GetUnderlyingType(type); + object parsed; + + if (value == "null" && (underlying != null || !type.IsValueType)) + { + parsed = null; + } + else if (underlying == null && type == typeof(bool)) + { + // The gump accepts the switch words as well as the literals. + if (comparison is not (ComparisonOperator.Equal or ComparisonOperator.NotEqual)) + { + return null; + } + + parsed = value.ToLowerInvariant() switch + { + "true" or "1" or "enabled" or "on" => true, + "false" or "0" or "disabled" or "off" => false, + _ => null + }; + + if (parsed == null) + { + return null; + } + } + else if (Types.TryParse(underlying ?? type, value, out parsed) != null) + { + return null; + } + + return new ComparisonCondition(property, negate, comparison.Value, parsed); + } + + private static ComparisonOperator? MapOperator(string op) => + op switch + { + "=" or "==" => ComparisonOperator.Equal, + "!" or "!=" => ComparisonOperator.NotEqual, + ">" => ComparisonOperator.Greater, + "<" => ComparisonOperator.Lesser, + ">=" => ComparisonOperator.GreaterEqual, + "<=" => ComparisonOperator.LesserEqual, + _ => null + }; + + // Case-insensitive, first readable match per link, the way the gump has always resolved a + // name. A dotted name walks into the property's type. + private static PropertyInfo[] Resolve(Type type, ReadOnlySpan name) + { + var count = name.Count('.') + 1; + var chain = new PropertyInfo[count]; + + for (var i = 0; i < count; ++i) + { + var dot = name.IndexOf('.'); + var segment = dot == -1 ? name : name[..dot]; + name = dot == -1 ? default : name[(dot + 1)..]; + + var found = Find(type, segment.Trim()); + + if (found == null) + { + return null; + } + + chain[i] = found; + type = found.PropertyType; + } + + return chain; + } + + private static PropertyInfo Find(Type type, ReadOnlySpan name) + { + var properties = _properties.GetOrAdd(type, static t => Readable(t)); + + for (var i = 0; i < properties.Length; ++i) + { + if (name.InsensitiveEquals(properties[i].Name)) + { + return properties[i]; + } + } + + return null; + } + + private static PropertyInfo[] Readable(Type type) + { + var all = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + var readable = new List(all.Length); + + for (var i = 0; i < all.Length; ++i) + { + if (all[i].CanRead && all[i].GetIndexParameters().Length == 0) + { + readable.Add(all[i]); + } + } + + return readable.ToArray(); + } + + /// + /// A floating-point comparison with the tolerance the gump derives from the typed value: a + /// value with no decimal point compares to within 1E-10, one with ten or more decimals to + /// within the last digit typed. + /// + private sealed class EpsilonCondition : ICondition + { + private static readonly MethodInfo _abs = typeof(Math).GetMethod(nameof(Math.Abs), [typeof(double)])!; + + private readonly Property _property; + private readonly bool _not; + private readonly ComparisonOperator _operator; + private readonly double _value; + private readonly double _epsilon; + + public EpsilonCondition(Property property, bool not, ComparisonOperator op, double value, double epsilon) + { + _property = property; + _not = not; + _operator = op; + _value = value; + _epsilon = epsilon; + } + + public Expression Build(ParameterExpression target) => + PropertyExpressions.Chain( + target, + _property, + read => + { + var value = read.Type == typeof(double) ? read : Expression.Convert(read, typeof(double)); + var constant = Expression.Constant(_value); + var epsilon = Expression.Constant(_epsilon); + var distance = Expression.Call(_abs, Expression.Subtract(value, constant)); + + Expression test = _operator switch + { + ComparisonOperator.Equal => Expression.LessThan(distance, epsilon), + ComparisonOperator.NotEqual => Expression.GreaterThanOrEqual(distance, epsilon), + ComparisonOperator.Greater => Expression.GreaterThan(value, Expression.Add(constant, epsilon)), + ComparisonOperator.Lesser => Expression.LessThan(value, Expression.Subtract(constant, epsilon)), + ComparisonOperator.GreaterEqual => Expression.GreaterThanOrEqual(value, Expression.Subtract(constant, epsilon)), + ComparisonOperator.LesserEqual => Expression.LessThanOrEqual(value, Expression.Add(constant, epsilon)), + _ => throw new InvalidOperationException("Invalid comparison operator.") + }; + + return _not ? Expression.Not(test) : test; + }, + Expression.Constant(false) + ); + } +} diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs index 7f72c02b2..96c357fd0 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs @@ -767,11 +767,12 @@ public class AdvancedSearchGump : Gump var ignoreQueue = new ConcurrentQueue(); var results = new ConcurrentQueue(); + var predicates = new AdvancedSearchConditions.Cache(); var worldLocation = new WorldLocation(from.Location, from.Map); for (var i = 0; i < _threadWorkers.Length; i++) { - (_threadWorkers[i] ??= new AdvancedSearchThreadWorker()).Wake(worldLocation, Filter, results, ignoreQueue); + (_threadWorkers[i] ??= new AdvancedSearchThreadWorker()).Wake(worldLocation, Filter, results, ignoreQueue, predicates); } var type = Filter.FilterType ? Filter.Type : null; diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs index 5206477c9..8f0c9c1a5 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Concurrent; -using System.Reflection; using System.Threading; using Server.Items; using Server.Logging; @@ -21,7 +20,6 @@ namespace Server.Engines.AdvancedSearch; public class AdvancedSearchThreadWorker { private static readonly ILogger _logger = LogFactory.GetLogger(typeof(AdvancedSearchThreadWorker)); - private static readonly ConcurrentDictionary _propCache = new(); private readonly Thread _thread; private readonly AutoResetEvent _startEvent; // Main thread tells the thread to start working @@ -33,6 +31,7 @@ public class AdvancedSearchThreadWorker private ConcurrentQueue _ignoreQueue; private WorldLocation _worldLocation; private AdvancedSearchFilter _filter; + private AdvancedSearchConditions.Cache _predicates; public AdvancedSearchThreadWorker() { @@ -46,17 +45,23 @@ public class AdvancedSearchThreadWorker _thread.Start(this); } + /// + /// Compiled property-test memo for this search. Shared across the workers so a type is + /// compiled once per search rather than once per worker; a lone worker may leave it null. + /// public void Wake( WorldLocation worldLocation, AdvancedSearchFilter filter, ConcurrentQueue results, - ConcurrentQueue ignoreQueue + ConcurrentQueue ignoreQueue, + AdvancedSearchConditions.Cache predicates = null ) { _worldLocation = worldLocation; _filter = filter; _ignoreQueue = ignoreQueue; _results = results; + _predicates = predicates ?? new AdvancedSearchConditions.Cache(); _startEvent.Set(); } @@ -108,6 +113,7 @@ public class AdvancedSearchThreadWorker { worker._results = null; worker._filter = null; + worker._predicates = null; // a compiled constant may pin an entity resolved by serial break; } else @@ -158,15 +164,7 @@ public class AdvancedSearchThreadWorker return null; } - // Check for valid map - if (_filter.FilterFelucca && entity.Map != Map.Felucca || - _filter.FilterTrammel && entity.Map != Map.Trammel || - _filter.FilterIlshenar && entity.Map != Map.Ilshenar || - _filter.FilterMalas && entity.Map != Map.Malas || - _filter.FilterTokuno && entity.Map != Map.Tokuno || - _filter.FilterTerMur && entity.Map != Map.TerMur || - _filter.FilterInternalMap && entity.Map != Map.Internal || - _filter.FilterNullMap && entity.Map != null) + if (!OnASelectedMap(entity.Map)) { return null; } @@ -208,6 +206,38 @@ public class AdvancedSearchThreadWorker return null; } + // The map boxes are independent checks, so several can be ticked at once: an entity passes when + // its map is any of them. With none ticked there is no map constraint. + private bool OnASelectedMap(Map map) + { + var f = _filter; + + var anySelected = f.FilterFelucca || f.FilterTrammel || f.FilterIlshenar || f.FilterMalas || + f.FilterTokuno || f.FilterTerMur || f.FilterInternalMap || f.FilterNullMap; + + if (!anySelected) + { + return true; + } + + if (map == null) + { + return f.FilterNullMap; + } + + if (map == Map.Internal) + { + return f.FilterInternalMap; + } + + return map == Map.Felucca && f.FilterFelucca || + map == Map.Trammel && f.FilterTrammel || + map == Map.Ilshenar && f.FilterIlshenar || + map == Map.Malas && f.FilterMalas || + map == Map.Tokuno && f.FilterTokuno || + map == Map.TerMur && f.FilterTerMur; + } + private static bool IsValidInternal(Item item) { if (item.Parent != null || item.HeldBy != null) @@ -249,8 +279,7 @@ public class AdvancedSearchThreadWorker return null; } - if (_filter.FilterPropertyTest && - (string.IsNullOrWhiteSpace(_filter.PropertyTest) || !EvaluateRecursive(item, _filter.PropertyTest))) + if (_filter.FilterPropertyTest && !PassesPropertyTest(item)) { return null; } @@ -273,8 +302,7 @@ public class AdvancedSearchThreadWorker return null; } - if (_filter.FilterPropertyTest && - (string.IsNullOrWhiteSpace(_filter.PropertyTest) || !EvaluateRecursive(mobile, _filter.PropertyTest))) + if (_filter.FilterPropertyTest && !PassesPropertyTest(mobile)) { return null; } @@ -353,58 +381,13 @@ public class AdvancedSearchThreadWorker } } - private static bool EvaluateRecursive(IEntity entity, ReadOnlySpan span) => - AdvancedSearchUtilities.EvaluateBoolean(span, entity, static (e, leaf) => EvaluateSingleExpression(e, leaf)); - - private static bool EvaluateSingleExpression(IEntity entity, ReadOnlySpan expression) + // The test is compiled once per runtime type for the search and memoized; after that each + // entity costs a dictionary lookup and a delegate call. + private bool PassesPropertyTest(IEntity entity) { - expression = expression.Trim(); - if (expression.Length == 0) - { - return false; - } + var test = _filter.PropertyTest; - var negate = false; - if (expression[0] == '~') - { - negate = true; - expression = expression[1..]; - } - - var operatorSpan = AdvancedSearchUtilities.FindOperatorIndex(expression, out var operatorIndex); - if (operatorSpan.Length == 0) - { - return false; - } - - var propertyName = expression[..operatorIndex].Trim(); - var valuePart = expression[(operatorIndex + operatorSpan.Length)..].Trim(); - - if (valuePart.Length == 0) - { - return false; - } - - var properties = _propCache.GetOrAdd(entity.GetType(), static t => t.GetProperties()); - PropertyInfo property = null; - for (var i = 0; i < properties.Length; ++i) - { - var p = properties[i]; - if (p.CanRead && p.Name.InsensitiveEquals(propertyName)) - { - property = p; - break; - } - } - - if (property == null) - { - return false; - } - - var propertyValue = property.GetValue(entity); - var result = AdvancedSearchUtilities.CompareValues(property.PropertyType, propertyValue, valuePart, operatorSpan); - - return negate ? !result : result; + return !string.IsNullOrWhiteSpace(test) && + AdvancedSearchConditions.GetPredicate(_predicates, entity.GetType(), test)(entity); } } diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs index 6775dcaef..f84cef41c 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs @@ -1,8 +1,5 @@ using System; using System.Buffers; -using System.Globalization; -using System.Numerics; -using System.Runtime.CompilerServices; namespace Server.Engines.AdvancedSearch; @@ -36,139 +33,6 @@ public static class AdvancedSearchUtilities return expression.Slice(index, 1); } - public static bool CompareValues(Type propertyType, object propertyValue, ReadOnlySpan valuePart, ReadOnlySpan operatorSpan) - { - // TODO: Add support for implicit conversion types like Serial -> uint - - if (propertyType == typeof(long)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((long)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(ulong)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((ulong)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(int)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((int)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(uint)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((uint)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(short)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((short)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(ushort)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((ushort)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(sbyte)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((sbyte)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(byte)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((byte)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(float)) - { - return TryParseValue(valuePart, out var parsedValue) && - Compare((float)propertyValue!, parsedValue, valuePart, operatorSpan); - } - if (propertyType == typeof(double)) - { - return TryParseValue(valuePart, out var parsedValue) && - Compare((double)propertyValue!, parsedValue, valuePart, operatorSpan); - } - if (propertyType == typeof(string)) - { - return TryParseValue(valuePart, out var parsedValue) && - Compare((string)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(TimeSpan)) - { - return TryParseValue(valuePart, out var parsedValue) && - Compare((TimeSpan)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(DateTime)) - { - return TryParseValue(valuePart, out var parsedValue) && - Compare((DateTime)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(bool)) - { - return TryParseValue(valuePart, out var parsedValue) && - Compare((bool)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType.IsEnum) - { - if (!Enum.TryParse(propertyType, valuePart.ToString(), true, out var valueEnum) || valueEnum == null) - { - return false; - } - - return GetEnumSize(propertyType) switch - { - 1 => CompareNumeric((byte)propertyValue!, (byte)valueEnum, operatorSpan), - 2 => CompareNumeric((short)propertyValue!, (short)valueEnum, operatorSpan), - 4 => CompareNumeric((int)propertyValue!, (int)valueEnum, operatorSpan), - 8 => CompareNumeric((long)propertyValue!, (long)valueEnum, operatorSpan), - _ => false - }; - } - // Anything the hot typed paths above didn't handle — reference types (Poison, Map, entity - // properties resolved by serial), IParsable value types (Guid, decimal, ...), and legacy - // RunUO types with a static Parse(string) (Faction, Town, ...). Delegate to the shared, - // thread-safe Types converter so the target is parsed into the property's real type, then - // compare by value. A string is allocated here, but this is the uncommon path; the common - // types never reach it. Types returns a non-null message when it can't parse -> no match. - return Types.TryParse(propertyType, valuePart.ToString(), out var parsed) == null && - CompareReference(propertyValue!, parsed, operatorSpan); - } - - public static bool CompareNumeric(T propertyValue, T parsedValue, ReadOnlySpan operatorSpan) where T : INumber => - operatorSpan switch - { - "=" or "==" => propertyValue == parsedValue, - "!" or "!=" => propertyValue != parsedValue, - ">" => propertyValue > parsedValue, - "<" => propertyValue < parsedValue, - ">=" => propertyValue >= parsedValue, - "<=" => propertyValue <= parsedValue, - _ => false - }; - - public static bool Compare( - double propertyValue, - double parsedValue, - ReadOnlySpan originalValue, - ReadOnlySpan operatorSpan - ) - { - var epsilon = CalculateEpsilon(originalValue); - - return operatorSpan switch - { - "=" or "==" => Math.Abs(propertyValue - parsedValue) < epsilon, - "!" or "!=" => Math.Abs(propertyValue - parsedValue) >= epsilon, - ">" => propertyValue > parsedValue + epsilon, - "<" => propertyValue < parsedValue - epsilon, - ">=" => propertyValue >= parsedValue - epsilon, - "<=" => propertyValue <= parsedValue + epsilon, - _ => throw new ArgumentException("Invalid operator") - }; - } - public static double CalculateEpsilon(ReadOnlySpan value) { var decimalPlace = value.IndexOf('.'); @@ -191,247 +55,4 @@ public static class AdvancedSearchUtilities _ => 1E-16 }; } - - public static bool Compare(string propertyValue, string parsedValue, ReadOnlySpan operatorSpan) => - operatorSpan switch - { - "=" or "==" => propertyValue.EqualsOrdinal(parsedValue), - "!" or "!=" => !propertyValue.EqualsOrdinal(parsedValue), - ">" => propertyValue.StartsWithOrdinal(parsedValue), - "<" => propertyValue.EndsWithOrdinal(parsedValue), - "~" => propertyValue.Contains(parsedValue), - "~<" => propertyValue.InsensitiveEndsWith(parsedValue), - "~>" => propertyValue.InsensitiveStartsWith(parsedValue), - "~~" => propertyValue.InsensitiveContains(parsedValue), - "~=" => propertyValue.InsensitiveEquals(parsedValue), - "~!" => !propertyValue.InsensitiveEquals(parsedValue), - _ => false - }; - - public static bool Compare(TimeSpan propertyValue, TimeSpan parsedValue, ReadOnlySpan operatorSpan) => - operatorSpan switch - { - "=" or "==" => propertyValue == parsedValue, - "!" or "!=" => propertyValue != parsedValue, - ">" => propertyValue > parsedValue, - "<" => propertyValue < parsedValue, - ">=" => propertyValue >= parsedValue, - "<=" => propertyValue <= parsedValue, - _ => false - }; - - public static bool Compare(DateTime propertyValue, DateTime parsedValue, ReadOnlySpan operatorSpan) => - operatorSpan switch - { - "=" or "==" => propertyValue == parsedValue, - "!" or "!=" => propertyValue != parsedValue, - ">" => propertyValue > parsedValue, - "<" => propertyValue < parsedValue, - ">=" => propertyValue >= parsedValue, - "<=" => propertyValue <= parsedValue, - _ => false - }; - - public static bool Compare(bool propertyValue, bool parsedValue, ReadOnlySpan operatorSpan) => - operatorSpan switch - { - "=" or "==" => propertyValue == parsedValue, - "!" or "!=" => propertyValue != parsedValue, - _ => false - }; - - public static bool CompareReference(T propertyValue, T parsedValue, ReadOnlySpan operatorSpan) - { - switch (operatorSpan) - { - case "=": - case "==": return Equals(propertyValue, parsedValue); - case "!": - case "!=": return !Equals(propertyValue, parsedValue); - } - - if (propertyValue is IComparable cmp && parsedValue != null) - { - try - { - var c = cmp.CompareTo(parsedValue); - return operatorSpan switch - { - ">" => c > 0, - "<" => c < 0, - ">=" => c >= 0, - "<=" => c <= 0, - _ => false - }; - } - catch - { - return false; - } - } - - return false; - } - - internal static bool TryParseValue(ReadOnlySpan valuePart, out T value) - { - // Special handling for boolean and hexadecimal values - if (typeof(T) == typeof(bool)) - { - var val = valuePart.ToString().ToLower(); - if (val is "true" or "1" or "enabled" or "on") - { - value = (T)(object)true; - return true; - } - - if (val is "false" or "0" or "disabled" or "off") - { - value = (T)(object)false; - return true; - } - - value = default; - return false; - } - - if (typeof(T) == typeof(long)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(ulong)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(int)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(uint)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(short)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(ushort)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(sbyte)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(byte)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(float)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(double)) - { - return TryParseNumericValue(valuePart, out value); - } - - // string needs no parsing — the span itself is the value. - if (typeof(T) == typeof(string)) - { - value = (T)(object)valuePart.ToString(); - return true; - } - - // Remaining supported types (TimeSpan, DateTime) parse straight from the span via - // ISpanParsable — no allocation, no reflection, and unlike Convert.ChangeType it handles - // TimeSpan, which is not IConvertible and previously failed silently. - if (typeof(T) == typeof(TimeSpan)) - { - return TryParseSpanParsable(valuePart, out value); - } - - if (typeof(T) == typeof(DateTime)) - { - return TryParseSpanParsable(valuePart, out value); - } - - value = default; - return false; - } - - // Parses U (a value type exposing ISpanParsable) from the span and reinterprets it as T. The - // two type params mirror TryParseNumericValue: the caller dispatches on typeof(T), so U == T at - // every call site and the (T)(object) cast is always valid. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool TryParseSpanParsable(ReadOnlySpan valuePart, out T value) where U : ISpanParsable - { - if (U.TryParse(valuePart, null, out var parsed)) - { - value = (T)(object)parsed; - return true; - } - - value = default; - return false; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool TryParseNumericValue(ReadOnlySpan valuePart, out R value) where T : INumber - { - var ok = valuePart.StartsWith("0x") - ? T.TryParse(valuePart[2..], NumberStyles.HexNumber, null, out var parsed) - : T.TryParse(valuePart, null, out parsed); - - if (ok) - { - value = (R)(object)parsed; - return true; - } - - value = default; - return false; - } - - // Evaluates one trimmed leaf atom against caller-supplied state. A custom delegate is required - // because ReadOnlySpan cannot be a Func<> type argument; passing state avoids a per-call - // capturing closure, so the recursion allocates neither a string nor a closure. - internal delegate bool LeafEvaluator(TState state, ReadOnlySpan leaf); - - // OR ('|') binds looser than AND ('@'); split on the outermost OR first, then AND. - internal static bool EvaluateBoolean(ReadOnlySpan expr, TState state, LeafEvaluator evalLeaf) - { - var orIndex = expr.IndexOf('|'); - if (orIndex != -1) - { - return EvaluateBoolean(expr[..orIndex], state, evalLeaf) || EvaluateBoolean(expr[(orIndex + 1)..], state, evalLeaf); - } - - var andIndex = expr.IndexOf('@'); - if (andIndex != -1) - { - return EvaluateBoolean(expr[..andIndex], state, evalLeaf) && EvaluateBoolean(expr[(andIndex + 1)..], state, evalLeaf); - } - - return evalLeaf(state, expr.Trim()); - } - - private static int GetEnumSize(Type enumType) => - Type.GetTypeCode(Enum.GetUnderlyingType(enumType)) switch - { - TypeCode.Byte or TypeCode.SByte => sizeof(byte), - TypeCode.Int16 or TypeCode.UInt16 => sizeof(ushort), - TypeCode.Int32 or TypeCode.UInt32 => sizeof(uint), - TypeCode.Int64 or TypeCode.UInt64 => sizeof(ulong), - _ => 4 - }; } diff --git a/Projects/UOContent/Misc/Emitter.cs b/Projects/UOContent/Misc/Emitter.cs deleted file mode 100644 index 61ba4cb7f..000000000 --- a/Projects/UOContent/Misc/Emitter.cs +++ /dev/null @@ -1,727 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using System.Reflection.Emit; - -namespace Server -{ - public class AssemblyEmitter - { - private readonly ModuleBuilder m_ModuleBuilder; - - public AssemblyEmitter(string assemblyName) - { - var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly( - new AssemblyName(assemblyName), - AssemblyBuilderAccess.Run - ); - - m_ModuleBuilder = assemblyBuilder.DefineDynamicModule(assemblyName); - } - - public TypeBuilder DefineType(string typeName, TypeAttributes attrs, Type parentType) => - m_ModuleBuilder.DefineType(typeName, attrs, parentType); - } - - public class MethodEmitter - { - public delegate void Callback(); - - private readonly Stack m_Calls; - - private readonly Stack m_Stack; - - private readonly Dictionary> m_Temps; - private Type[] m_ArgumentTypes; - - public MethodEmitter(TypeBuilder typeBuilder) - { - Type = typeBuilder; - - m_Temps = new Dictionary>(); - - m_Stack = new Stack(); - m_Calls = new Stack(); - } - - public TypeBuilder Type { get; } - - public ILGenerator Generator { get; private set; } - - public MethodBuilder Method { get; private set; } - - public Type Active => m_Stack.Peek(); - - public void Define(string name, MethodAttributes attr, Type returnType, Type[] parms) - { - Method = Type.DefineMethod(name, attr, returnType, parms); - Generator = Method.GetILGenerator(); - - m_ArgumentTypes = parms; - } - - public LocalBuilder CreateLocal(Type localType) => Generator.DeclareLocal(localType); - - public LocalBuilder AcquireTemp(Type localType) - { - if (!m_Temps.TryGetValue(localType, out var list)) - { - m_Temps[localType] = list = new Queue(); - } - - return list.Count > 0 ? list.Dequeue() : CreateLocal(localType); - } - - public void ReleaseTemp(LocalBuilder local) - { - if (local.LocalType == null) - { - return; - } - - if (!m_Temps.TryGetValue(local.LocalType, out var list)) - { - m_Temps[local.LocalType] = list = new Queue(); - } - - list.Enqueue(local); - } - - public void Branch(Label label) - { - Generator.Emit(OpCodes.Br, label); - } - - public void BranchIfFalse(Label label) - { - Pop(typeof(object)); - - Generator.Emit(OpCodes.Brfalse, label); - } - - public void BranchIfTrue(Label label) - { - Pop(typeof(object)); - - Generator.Emit(OpCodes.Brtrue, label); - } - - public Label CreateLabel() => Generator.DefineLabel(); - - public void MarkLabel(Label label) - { - Generator.MarkLabel(label); - } - - public void Pop() - { - m_Stack.Pop(); - } - - public void Pop(Type expected) - { - if (expected == null) - { - throw new InvalidOperationException("Expected type cannot be null."); - } - - var onStack = m_Stack.Pop(); - - if (expected == typeof(bool)) - { - expected = typeof(int); - } - - if (onStack == typeof(bool)) - { - onStack = typeof(int); - } - - if (!expected.IsAssignableFrom(onStack)) - { - throw new InvalidOperationException("Unexpected stack state."); - } - } - - public void Push(Type type) - { - m_Stack.Push(type); - } - - public void Return() - { - if (m_Stack.Count != (Method.ReturnType == typeof(void) ? 0 : 1)) - { - throw new InvalidOperationException("Stack return mismatch."); - } - - Generator.Emit(OpCodes.Ret); - } - - public void LoadNull() - { - LoadNull(typeof(object)); - } - - public void LoadNull(Type type) - { - Push(type); - - Generator.Emit(OpCodes.Ldnull); - } - - public void Load(string value) - { - Push(typeof(string)); - - if (value != null) - { - Generator.Emit(OpCodes.Ldstr, value); - } - else - { - Generator.Emit(OpCodes.Ldnull); - } - } - - public void Load(Enum value) - { - var toLoad = ((IConvertible)value).ToInt32(null); - Load(toLoad); - - Pop(); - Push(value.GetType()); - } - - public void Load(long value) - { - Push(typeof(long)); - - Generator.Emit(OpCodes.Ldc_I8, value); - } - - public void Load(float value) - { - Push(typeof(float)); - - Generator.Emit(OpCodes.Ldc_R4, value); - } - - public void Load(double value) - { - Push(typeof(double)); - - Generator.Emit(OpCodes.Ldc_R8, value); - } - - public void Load(char value) - { - Load((int)value); - - Pop(); - Push(typeof(char)); - } - - public void Load(bool value) - { - Push(typeof(bool)); - - if (value) - { - Generator.Emit(OpCodes.Ldc_I4_1); - } - else - { - Generator.Emit(OpCodes.Ldc_I4_0); - } - } - - public void Load(int value) - { - Push(typeof(int)); - - switch (value) - { - case -1: - Generator.Emit(OpCodes.Ldc_I4_M1); - break; - - case 0: - Generator.Emit(OpCodes.Ldc_I4_0); - break; - - case 1: - Generator.Emit(OpCodes.Ldc_I4_1); - break; - - case 2: - Generator.Emit(OpCodes.Ldc_I4_2); - break; - - case 3: - Generator.Emit(OpCodes.Ldc_I4_3); - break; - - case 4: - Generator.Emit(OpCodes.Ldc_I4_4); - break; - - case 5: - Generator.Emit(OpCodes.Ldc_I4_5); - break; - - case 6: - Generator.Emit(OpCodes.Ldc_I4_6); - break; - - case 7: - Generator.Emit(OpCodes.Ldc_I4_7); - break; - - case 8: - Generator.Emit(OpCodes.Ldc_I4_8); - break; - - default: - if (value >= sbyte.MinValue && value <= sbyte.MaxValue) - { - Generator.Emit(OpCodes.Ldc_I4_S, (sbyte)value); - } - else - { - Generator.Emit(OpCodes.Ldc_I4, value); - } - - break; - } - } - - public void LoadField(FieldInfo field) - { - Pop(field.DeclaringType); - - Push(field.FieldType); - - Generator.Emit(OpCodes.Ldfld, field); - } - - public void LoadLocal(LocalBuilder local) - { - Push(local.LocalType); - - var index = local.LocalIndex; - - switch (index) - { - case 0: - Generator.Emit(OpCodes.Ldloc_0); - break; - - case 1: - Generator.Emit(OpCodes.Ldloc_1); - break; - - case 2: - Generator.Emit(OpCodes.Ldloc_2); - break; - - case 3: - Generator.Emit(OpCodes.Ldloc_3); - break; - - default: - if (index >= byte.MinValue && index <= byte.MinValue) - { - Generator.Emit(OpCodes.Ldloc_S, (byte)index); - } - else - { - Generator.Emit(OpCodes.Ldloc, (short)index); - } - - break; - } - } - - public void StoreLocal(LocalBuilder local) - { - Pop(local.LocalType); - - Generator.Emit(OpCodes.Stloc, local); - } - - public void LoadArgument(int index) - { - if (index > 0) - { - Push(m_ArgumentTypes[index - 1]); - } - else - { - Push(Type); - } - - switch (index) - { - case 0: - Generator.Emit(OpCodes.Ldarg_0); - break; - - case 1: - Generator.Emit(OpCodes.Ldarg_1); - break; - - case 2: - Generator.Emit(OpCodes.Ldarg_2); - break; - - case 3: - Generator.Emit(OpCodes.Ldarg_3); - break; - - default: - if (index >= byte.MinValue && index <= byte.MaxValue) - { - Generator.Emit(OpCodes.Ldarg_S, (byte)index); - } - else - { - Generator.Emit(OpCodes.Ldarg, (short)index); - } - - break; - } - } - - public void CastAs(Type type) - { - Pop(typeof(object)); - Push(type); - - Generator.Emit(OpCodes.Isinst, type); - } - - public void Neg() - { - Pop(typeof(int)); - - Push(typeof(int)); - - Generator.Emit(OpCodes.Neg); - } - - public void Compare(OpCode opCode) - { - Pop(); - Pop(); - - Push(typeof(int)); - - Generator.Emit(opCode); - } - - public void LogicalNot() - { - Pop(typeof(int)); - - Push(typeof(int)); - - Generator.Emit(OpCodes.Ldc_I4_0); - Generator.Emit(OpCodes.Ceq); - } - - public void Xor() - { - Pop(typeof(int)); - Pop(typeof(int)); - - Push(typeof(int)); - - Generator.Emit(OpCodes.Xor); - } - - public void Chain(Property prop) - { - for (var i = 0; i < prop.Chain.Length; ++i) - { - Call(prop.Chain[i].GetGetMethod()); - } - } - - public void Call(MethodInfo method) - { - BeginCall(method); - - var call = m_Calls.Peek(); - - if (call.parms.Length > 0) - { - throw new InvalidOperationException("Method requires parameters."); - } - - FinishCall(); - } - - public bool CompareTo(int sign, Callback argGenerator) - { - var active = Active; - - var compareTo = active.GetMethod("CompareTo", new[] { active }); - - if (compareTo == null) - { - /* This gets a little tricky... - * - * There's a scenario where we might be trying to use CompareTo on an interface - * which, while it doesn't explicitly implement CompareTo itself, is said to - * extend IComparable indirectly. The implementation is implicitly passed off - * to implementers... - * - * interface ISomeInterface : IComparable - * { - * void SomeMethod(); - * } - * - * class SomeClass : ISomeInterface - * { - * void SomeMethod() { ... } - * int CompareTo( object other ) { ... } - * } - * - * In this case, calling ISomeInterface.GetMethod( "CompareTo" ) will return null. - * - * Bleh. - */ - - var ifaces = active.FindInterfaces( - (type, obj) => type.IsGenericType - && type.GetGenericTypeDefinition() == typeof(IComparable<>) - && type.GetGenericArguments()[0].IsAssignableFrom(active), - null - ); - - if (ifaces.Length > 0) - { - compareTo = ifaces[0].GetMethod("CompareTo", new[] { active }); - } - else - { - ifaces = active.FindInterfaces((type, obj) => type == typeof(IComparable), null); - - if (ifaces.Length > 0) - { - compareTo = ifaces[0].GetMethod("CompareTo", new[] { active }); - } - } - } - - if (compareTo == null) - { - return false; - } - - if (!active.IsValueType) - { - /* This object is a reference type, so we have to make it behave - * - * null.CompareTo( null ) = 0 - * real.CompareTo( null ) = -1 - * null.CompareTo( real ) = +1 - * - */ - - var aValue = AcquireTemp(active); - var bValue = AcquireTemp(active); - - StoreLocal(aValue); - - argGenerator(); - - StoreLocal(bValue); - - /* if (aValue == null) - * { - * if (bValue == null) - * v = 0; - * else - * v = +1; - * } - * else if (bValue == null) - * { - * v = -1; - * } - * else - * { - * v = aValue.CompareTo( bValue ); - * } - */ - - var store = CreateLabel(); - - var aNotNull = CreateLabel(); - - LoadLocal(aValue); - BranchIfTrue(aNotNull); - // if (aValue == null) - { - var bNotNull = CreateLabel(); - - LoadLocal(bValue); - BranchIfTrue(bNotNull); - // if (bValue == null) - { - Load(0); - Pop(typeof(int)); - Branch(store); - } - MarkLabel(bNotNull); - // else - { - Load(sign); - Pop(typeof(int)); - Branch(store); - } - } - MarkLabel(aNotNull); - // else - { - var bNotNull = CreateLabel(); - - LoadLocal(bValue); - BranchIfTrue(bNotNull); - // bValue == null - { - Load(-sign); - Pop(typeof(int)); - Branch(store); - } - MarkLabel(bNotNull); - // else - { - LoadLocal(aValue); - BeginCall(compareTo); - - LoadLocal(bValue); - ArgumentPushed(); - - FinishCall(); - - if (sign == -1) - { - Neg(); - } - } - } - - MarkLabel(store); - - ReleaseTemp(aValue); - ReleaseTemp(bValue); - } - else - { - BeginCall(compareTo); - - argGenerator(); - - ArgumentPushed(); - - FinishCall(); - - if (sign == -1) - { - Neg(); - } - } - - return true; - } - - public void BeginCall(MethodInfo method) - { - var type = (method.CallingConvention & CallingConventions.HasThis) != 0 ? m_Stack.Peek() : method.DeclaringType; - - m_Calls.Push(new CallInfo(type, method)); - - if (type!.IsValueType) - { - var temp = AcquireTemp(type); - - Generator.Emit(OpCodes.Stloc, temp); - Generator.Emit(OpCodes.Ldloca, temp); - - ReleaseTemp(temp); - } - } - - public void FinishCall() - { - var call = m_Calls.Pop(); - - if ((call.type.IsValueType || call.type.IsByRef) && call.method.DeclaringType != call.type) - { - Generator.Emit(OpCodes.Constrained, call.type); - } - - if (call.method.DeclaringType?.IsValueType == true || call.method.IsStatic) - { - Generator.Emit(OpCodes.Call, call.method); - } - else - { - Generator.Emit(OpCodes.Callvirt, call.method); - } - - for (var i = call.parms.Length - 1; i >= 0; --i) - { - Pop(call.parms[i].ParameterType); - } - - if ((call.method.CallingConvention & CallingConventions.HasThis) != 0) - { - Pop(call.method.DeclaringType); - } - - if (call.method.ReturnType != typeof(void)) - { - Push(call.method.ReturnType); - } - } - - public void ArgumentPushed() - { - var call = m_Calls.Peek(); - - var parm = call.parms[call.index++]; - - var argumentType = m_Stack.Peek(); - - if (!parm.ParameterType.IsAssignableFrom(argumentType)) - { - throw new InvalidOperationException("Parameter type mismatch."); - } - - if (argumentType.IsValueType && !parm.ParameterType.IsValueType) - { - Generator.Emit(OpCodes.Box, argumentType); - } - } - - private class CallInfo - { - public readonly MethodInfo method; - public readonly ParameterInfo[] parms; - public readonly Type type; - - public int index; - - public CallInfo(Type type, MethodInfo method) - { - this.type = type; - this.method = method; - - parms = method.GetParameters(); - } - } - } -}