From 535a0989968258b92404fbd80a7581258af3093b Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:01:59 -0700 Subject: [PATCH] refactor: compile where/sort/distinct and Advanced Search through expression trees (#2625) ## Summary `where`, `sort by`, `distinct` and the Advanced Search property test now compile through expression trees instead of the hand-rolled IL in `Emitter.cs`. The emitter and its three `Reflection.Emit` compilers were RunUO-era code from before expression trees existed; they were the only way to avoid per-object reflection at the time, and they are not any more. Net: ~1,900 lines of IL bookkeeping deleted, one comparison engine instead of two, faster per object, cheaper to compile, collectible, and `Nullable` properties work. ## Why - **Bugs hid in the IL.** Equality on a type with value semantics but no `IComparable` (`TextDefinition`) was a raw `ceq`, so `where Message = 1060847` never matched. A chained binding (`Message.Number`) dereferenced every link unguarded, so the first swept object with a null intermediate killed the sweep with an NRE. A constant narrower or unsigned than `int` threw before compiling. A struct with no `CompareTo` reached `ceq` on two unboxed values, which is invalid IL. Every dynamic assembly was `Run`, so each `[global where` grew the process for good. - **Advanced Search had its own engine.** Per entity and per leaf it re-split the expression, scanned the runtime type's properties by name, read the value by reflection, re-parsed the right-hand side and dispatched on type through ~300 lines of `CompareValues`. Same job, second implementation, second set of bugs. ## What changed - `ICondition.Compile(MethodEmitter)` becomes `ICondition.Build(ParameterExpression)` returning an `Expression`. `ConditionalCompiler` assembles a `Func`, `SortCompiler` a `Comparison`, `DistinctCompiler` both comparer interfaces over one lambda. The parsed constant is an `Expression.Constant`, so the generated type, its constructor and the per-condition field for non-primitive constants disappear with `PropertyValue`. - `PropertyExpressions` holds the shared pieces: the chain walk with its null-intermediate guard, `CompareTo` resolution with the old null ordering, the integral/enum operator path, and constant parsing. - `BaseExtension.Optimize` loses its `ref AssemblyEmitter` parameter. An out-of-tree extension that overrides `Optimize` needs to drop it. - Advanced Search keeps its grammar (`~` negates, `@` is AND, `|` is OR and binds looser, string `>` is "starts with") and translates each leaf into the same conditions, compiled once per declaring type per search and memoized across the workers. Float and double keep their typed-precision tolerance through a small `EpsilonCondition` in the Advanced Search folder. ## Semantics preserved - Equality on a non-comparable reference type is `object.Equals`, never reference identity. A non-comparable struct boxes into the same call. - A null intermediate in a chained binding is no match, and stays no match under negation. Sort and distinct read it as `default(T)`. - Unsigned relational compares stay unsigned; integral primitives and enums use the operator directly, nothing widens to a signed type. `float`, `double`, `decimal`, `string` and structs still go through the type's own `CompareTo`, so `string` equality stays culture-sensitive exactly as before. - Only `==` and `!=` are valid for non-comparable types; a relational operator still throws at build time. - `TypeCondition` is still first and still null-checks the cast target. ## Behavior changes - **`Nullable` works**, with C# lifted semantics in `where`: two nulls are equal, a null and a value are unequal, a null satisfies no relation. Sort keeps a total order with unset values at one end. A null *reference* keeps the ordering it had. - **Advanced Search**: a leaf that cannot be parsed or resolved is no match even under `~` (it used to negate the failure and match every entity); `null` is the null value for equality on strings, nullables and reference types, as in `where`; dotted names walk into a property; static properties are no longer searchable. ## Measurements `where`, from the handoff (Debug test host, single condition, ratios not absolutes): | Approach | ns/object | Compile | Collectible | LOC | |---|---:|---:|---|---:| | `AssemblyBuilder` + IL (before) | 20.5 | 0.311 ms | no (`Run`) | ~1,916 | | Expression trees (after) | ~12 | 0.187 ms warm | yes | ~600 | Advanced Search, Release, one `SkillTeleporter`, 2M evaluations per leaf: | Leaf | Before | After | |---|---:|---:| | `Hue=5` | 137 ns | 20 ns | | `Name~~gate` | 121 ns | 39 ns | | `Skill=Magery` | 116 ns | 21 ns | | `Weight>0.5` | 122 ns | 28 ns | Plus 0.5 to 2 ms to compile each declaring type a search meets (12 ms for the first compile in the process). All of it runs on the search workers; nothing new touches the loop. ## Also fixed: Advanced Search map filters Found while testing the property test in game. The map boxes are independent checkboxes, but the worker applied each ticked map as "must be on this map", so ticking two or more (all maps and Internal, say) rejected every entity before any other filter ran. Present since #1649; the default of Felucca alone never showed it. An entity now passes when its map is any of the ticked ones, with none ticked meaning no map constraint. Pinned by a worker test. ## Test plan - [x] `UOContent.Tests`: 862 passed, 0 failed - [x] `Server.Tests`: 869 passed, 0 failed - [x] Every commit builds and its tests pass on its own (bisectable) - [ ] In game: `[global where`, `[area where`, `[condition`, `sort by`, `distinct`, Advanced Search property test --- .../Tests/Commands/ChainedBindingSortTests.cs | 113 +++ .../Commands/ConditionalCompilerEdgeTests.cs | 112 +++ .../Tests/Commands/DistinctComparerTests.cs | 97 +++ .../Tests/Commands/EmitterRobustnessTests.cs | 103 +++ .../NullablePropertyConditionTests.cs | 143 ++++ .../Tests/Commands/ObjectConditionalTests.cs | 200 +++++ .../AdvancedSearchConditionsTests.cs | 232 ++++++ .../AdvancedSearchTypesTests.cs | 24 +- .../AdvancedSearchUtilitiesTests.cs | 119 --- .../AdvancedSearchWorkerTests.cs | 105 +++ .../Generic/Extensions/BaseExtension.cs | 6 +- .../Compilers/ConditionalCompiler.cs | 614 ++++----------- .../Extensions/Compilers/DistinctCompiler.cs | 299 ++----- .../Compilers/PropertyExpressions.cs | 407 ++++++++++ .../Extensions/Compilers/SortCompiler.cs | 178 ++--- .../Generic/Extensions/DistinctExtension.cs | 6 +- .../Generic/Extensions/SortExtension.cs | 6 +- .../Generic/Extensions/WhereExtension.cs | 4 +- .../Generic/Implementors/ObjectConditional.cs | 10 +- .../AdvancedSearchConditions.cs | 432 +++++++++++ .../Advanced Search/AdvancedSearchGump.cs | 3 +- .../AdvancedSearchThreadWorker.cs | 117 ++- .../AdvancedSearchUtilities.cs | 379 --------- Projects/UOContent/Misc/Emitter.cs | 727 ------------------ 24 files changed, 2294 insertions(+), 2142 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Commands/NullablePropertyConditionTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs delete mode 100644 Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs create mode 100644 Projects/UOContent/Commands/Generic/Extensions/Compilers/PropertyExpressions.cs create mode 100644 Projects/UOContent/Engines/Advanced Search/AdvancedSearchConditions.cs delete mode 100644 Projects/UOContent/Misc/Emitter.cs 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(); - } - } - } -}