## 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<T>` 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<object, bool>`, `SortCompiler` a `Comparison<T>`, `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<T>` 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
248 lines
9 KiB
C#
248 lines
9 KiB
C#
using System;
|
|
using System.Linq.Expressions;
|
|
using System.Reflection;
|
|
|
|
namespace Server.Commands.Generic
|
|
{
|
|
public interface IConditional
|
|
{
|
|
bool Verify(object obj);
|
|
}
|
|
|
|
public interface ICondition
|
|
{
|
|
// `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();
|
|
|
|
Expression ICondition.Build(ParameterExpression target) =>
|
|
Expression.ReferenceNotEqual(target, Expression.Constant(null, target.Type));
|
|
}
|
|
|
|
public abstract class PropertyCondition : ICondition
|
|
{
|
|
protected bool m_Not;
|
|
protected Property m_Property;
|
|
|
|
public PropertyCondition(Property property, bool not)
|
|
{
|
|
m_Property = property;
|
|
m_Not = not;
|
|
}
|
|
|
|
public abstract Expression Build(ParameterExpression target);
|
|
|
|
// 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<Expression, Expression> test) =>
|
|
PropertyExpressions.Chain(
|
|
target,
|
|
m_Property,
|
|
value =>
|
|
{
|
|
var result = test(value);
|
|
return m_Not ? Expression.Not(result) : result;
|
|
},
|
|
Expression.Constant(false)
|
|
);
|
|
}
|
|
|
|
public enum StringOperator
|
|
{
|
|
Equal,
|
|
NotEqual,
|
|
|
|
Contains,
|
|
StartsWith,
|
|
EndsWith
|
|
}
|
|
|
|
public sealed class StringCondition : PropertyCondition
|
|
{
|
|
private readonly bool m_IgnoreCase;
|
|
private readonly StringOperator m_Operator;
|
|
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 = value;
|
|
m_IgnoreCase = ignoreCase;
|
|
}
|
|
|
|
public override Expression Build(ParameterExpression target)
|
|
{
|
|
if (m_Property.Type != typeof(string))
|
|
{
|
|
throw new InvalidOperationException("String operators require a string property.");
|
|
}
|
|
|
|
var inverse = m_Operator == StringOperator.NotEqual;
|
|
|
|
var methodName = m_Operator switch
|
|
{
|
|
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.")
|
|
};
|
|
|
|
var helper = (m_IgnoreCase ? typeof(InsensitiveStringHelpers) : typeof(OrdinalStringHelpers)).GetMethod(
|
|
methodName,
|
|
BindingFlags.Public | BindingFlags.Static,
|
|
null,
|
|
[typeof(string), typeof(string)],
|
|
null
|
|
);
|
|
|
|
var constant = PropertyExpressions.Constant(typeof(string), m_Value);
|
|
|
|
return Guarded(
|
|
target,
|
|
value =>
|
|
{
|
|
Expression test = Expression.Call(helper, value, constant);
|
|
|
|
// 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
|
|
);
|
|
}
|
|
|
|
return inverse ? Expression.Not(test) : test;
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
public enum ComparisonOperator
|
|
{
|
|
Equal,
|
|
NotEqual,
|
|
Greater,
|
|
GreaterEqual,
|
|
Lesser,
|
|
LesserEqual
|
|
}
|
|
|
|
public sealed class ComparisonCondition : PropertyCondition
|
|
{
|
|
private readonly ComparisonOperator m_Operator;
|
|
private readonly object m_Value;
|
|
|
|
public ComparisonCondition(Property property, bool not, ComparisonOperator op, object value)
|
|
: base(property, not)
|
|
{
|
|
m_Operator = op;
|
|
m_Value = value;
|
|
}
|
|
|
|
public override Expression Build(ParameterExpression target)
|
|
{
|
|
var constant = PropertyExpressions.Constant(m_Property.Type, m_Value);
|
|
|
|
return Guarded(
|
|
target,
|
|
value =>
|
|
{
|
|
if (PropertyExpressions.TryRelational(value, constant, m_Operator, out var test))
|
|
{
|
|
return test;
|
|
}
|
|
|
|
// 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.")
|
|
};
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
public static class ConditionalCompiler
|
|
{
|
|
private sealed class CompiledConditional : IConditional
|
|
{
|
|
private readonly Func<object, bool> _verify;
|
|
|
|
public CompiledConditional(Func<object, bool> verify) => _verify = verify;
|
|
|
|
public bool Verify(object obj) => _verify(obj);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compiles a conjunction of conditions over <paramref name="objectType" /> into a single
|
|
/// delegate. The conditions short-circuit left to right, so <see cref="TypeCondition" />
|
|
/// comes first and the rest can assume a non-null, correctly typed target.
|
|
/// </summary>
|
|
public static IConditional Compile(Type objectType, ICondition[] conditions) =>
|
|
new CompiledConditional(Build(objectType, conditions).Compile());
|
|
|
|
public static Expression<Func<object, bool>> Build(Type objectType, ICondition[] conditions) =>
|
|
Lambda(objectType, target => Conjunction(target, conditions));
|
|
|
|
/// <summary>
|
|
/// A disjunction of conjunctions -- <c>(a and b) or (c and d)</c> -- as one lambda, for
|
|
/// callers that would otherwise compile every group separately and loop over them.
|
|
/// </summary>
|
|
public static Expression<Func<object, bool>> Build(Type objectType, ICondition[][] groups) =>
|
|
Lambda(
|
|
objectType,
|
|
target =>
|
|
{
|
|
Expression body = groups.Length > 0 ? Conjunction(target, groups[0]) : Expression.Constant(false);
|
|
|
|
for (var i = 1; i < groups.Length; ++i)
|
|
{
|
|
body = Expression.OrElse(body, Conjunction(target, groups[i]));
|
|
}
|
|
|
|
return body;
|
|
}
|
|
);
|
|
|
|
private static Expression Conjunction(ParameterExpression target, ICondition[] conditions)
|
|
{
|
|
Expression body = conditions.Length > 0 ? conditions[0].Build(target) : Expression.Constant(true);
|
|
|
|
for (var i = 1; i < conditions.Length; ++i)
|
|
{
|
|
body = Expression.AndAlso(body, conditions[i].Build(target));
|
|
}
|
|
|
|
return body;
|
|
}
|
|
|
|
private static Expression<Func<object, bool>> Lambda(Type objectType, Func<ParameterExpression, Expression> body)
|
|
{
|
|
var obj = Expression.Parameter(typeof(object), "obj");
|
|
var target = Expression.Variable(objectType, "target");
|
|
|
|
return Expression.Lambda<Func<object, bool>>(
|
|
Expression.Block(
|
|
[target],
|
|
Expression.Assign(target, Expression.TypeAs(obj, objectType)),
|
|
body(target)
|
|
),
|
|
obj
|
|
);
|
|
}
|
|
}
|
|
}
|