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<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
This commit is contained in:
parent
865a8cf218
commit
535a098996
24 changed files with 2294 additions and 2142 deletions
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Turns a property-test string from the Advanced Search gump into a compiled predicate. The
|
||||
/// grammar is the gump's own -- <c>~</c> negates a leaf, <c>@</c> is AND, <c>|</c> is OR and binds
|
||||
/// looser, and the string operators double up (<c>></c> is "starts with" on a string) -- but
|
||||
/// each leaf becomes the same <see cref="ICondition" /> a <c>where</c> clause compiles, so there is
|
||||
/// one comparison engine. A leaf that cannot be resolved or parsed is simply "no match".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Runs on the search workers, off the game loop: binding is reflection, compiling is
|
||||
/// <c>Expression.Compile</c>, and neither touches game state. The one exception is a value that
|
||||
/// names an entity by serial, which <see cref="Types.TryParse" /> resolves through the world --
|
||||
/// the same read the previous per-entity evaluator made, now made once per type instead.
|
||||
/// </remarks>
|
||||
public static class AdvancedSearchConditions
|
||||
{
|
||||
// Runtime type -> its public readable instance properties, for the case-insensitive name scan.
|
||||
private static readonly ConcurrentDictionary<Type, PropertyInfo[]> _properties = new();
|
||||
|
||||
private static readonly Func<object, bool> _never = static _ => false;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>Hue</c> shares one compiled <c>Hue = 5</c>.
|
||||
/// </summary>
|
||||
public sealed class Cache
|
||||
{
|
||||
internal readonly ConcurrentDictionary<Type, Func<object, bool>> ByRuntimeType = new();
|
||||
internal readonly ConcurrentDictionary<Type, Func<object, bool>> ByCompiledType = new();
|
||||
}
|
||||
|
||||
public static Func<object, bool> GetPredicate(Cache cache, Type runtimeType, string propertyTest) =>
|
||||
cache.ByRuntimeType.GetOrAdd(
|
||||
runtimeType,
|
||||
static (type, state) => Build(state.cache, type, state.propertyTest),
|
||||
(cache, propertyTest)
|
||||
);
|
||||
|
||||
/// <summary>Compiles without a cache. Test seam and one-off use.</summary>
|
||||
public static Func<object, bool> Compile(Type runtimeType, string propertyTest) =>
|
||||
Build(new Cache(), runtimeType, propertyTest);
|
||||
|
||||
private static Func<object, bool> 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<ICondition[]>();
|
||||
|
||||
foreach (var orPart in propertyTest.Split('|'))
|
||||
{
|
||||
var group = new List<ICondition> { 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<char> 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<char> 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<char> 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<PropertyInfo>(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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -767,11 +767,12 @@ public class AdvancedSearchGump : Gump
|
|||
|
||||
var ignoreQueue = new ConcurrentQueue<IEntity>();
|
||||
var results = new ConcurrentQueue<AdvancedSearchResult>();
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<Type, PropertyInfo[]> _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<IEntity> _ignoreQueue;
|
||||
private WorldLocation _worldLocation;
|
||||
private AdvancedSearchFilter _filter;
|
||||
private AdvancedSearchConditions.Cache _predicates;
|
||||
|
||||
public AdvancedSearchThreadWorker()
|
||||
{
|
||||
|
|
@ -46,17 +45,23 @@ public class AdvancedSearchThreadWorker
|
|||
_thread.Start(this);
|
||||
}
|
||||
|
||||
/// <param name="predicates">
|
||||
/// 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.
|
||||
/// </param>
|
||||
public void Wake(
|
||||
WorldLocation worldLocation,
|
||||
AdvancedSearchFilter filter,
|
||||
ConcurrentQueue<AdvancedSearchResult> results,
|
||||
ConcurrentQueue<IEntity> ignoreQueue
|
||||
ConcurrentQueue<IEntity> 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<char> span) =>
|
||||
AdvancedSearchUtilities.EvaluateBoolean(span, entity, static (e, leaf) => EvaluateSingleExpression(e, leaf));
|
||||
|
||||
private static bool EvaluateSingleExpression(IEntity entity, ReadOnlySpan<char> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<char> valuePart, ReadOnlySpan<char> operatorSpan)
|
||||
{
|
||||
// TODO: Add support for implicit conversion types like Serial -> uint
|
||||
|
||||
if (propertyType == typeof(long))
|
||||
{
|
||||
return TryParseValue<long>(valuePart, out var parsedValue) &&
|
||||
CompareNumeric((long)propertyValue!, parsedValue, operatorSpan);
|
||||
}
|
||||
if (propertyType == typeof(ulong))
|
||||
{
|
||||
return TryParseValue<ulong>(valuePart, out var parsedValue) &&
|
||||
CompareNumeric((ulong)propertyValue!, parsedValue, operatorSpan);
|
||||
}
|
||||
if (propertyType == typeof(int))
|
||||
{
|
||||
return TryParseValue<int>(valuePart, out var parsedValue) &&
|
||||
CompareNumeric((int)propertyValue!, parsedValue, operatorSpan);
|
||||
}
|
||||
if (propertyType == typeof(uint))
|
||||
{
|
||||
return TryParseValue<uint>(valuePart, out var parsedValue) &&
|
||||
CompareNumeric((uint)propertyValue!, parsedValue, operatorSpan);
|
||||
}
|
||||
if (propertyType == typeof(short))
|
||||
{
|
||||
return TryParseValue<short>(valuePart, out var parsedValue) &&
|
||||
CompareNumeric((short)propertyValue!, parsedValue, operatorSpan);
|
||||
}
|
||||
if (propertyType == typeof(ushort))
|
||||
{
|
||||
return TryParseValue<ushort>(valuePart, out var parsedValue) &&
|
||||
CompareNumeric((ushort)propertyValue!, parsedValue, operatorSpan);
|
||||
}
|
||||
if (propertyType == typeof(sbyte))
|
||||
{
|
||||
return TryParseValue<sbyte>(valuePart, out var parsedValue) &&
|
||||
CompareNumeric((sbyte)propertyValue!, parsedValue, operatorSpan);
|
||||
}
|
||||
if (propertyType == typeof(byte))
|
||||
{
|
||||
return TryParseValue<byte>(valuePart, out var parsedValue) &&
|
||||
CompareNumeric((byte)propertyValue!, parsedValue, operatorSpan);
|
||||
}
|
||||
if (propertyType == typeof(float))
|
||||
{
|
||||
return TryParseValue<float>(valuePart, out var parsedValue) &&
|
||||
Compare((float)propertyValue!, parsedValue, valuePart, operatorSpan);
|
||||
}
|
||||
if (propertyType == typeof(double))
|
||||
{
|
||||
return TryParseValue<double>(valuePart, out var parsedValue) &&
|
||||
Compare((double)propertyValue!, parsedValue, valuePart, operatorSpan);
|
||||
}
|
||||
if (propertyType == typeof(string))
|
||||
{
|
||||
return TryParseValue<string>(valuePart, out var parsedValue) &&
|
||||
Compare((string)propertyValue!, parsedValue, operatorSpan);
|
||||
}
|
||||
if (propertyType == typeof(TimeSpan))
|
||||
{
|
||||
return TryParseValue<TimeSpan>(valuePart, out var parsedValue) &&
|
||||
Compare((TimeSpan)propertyValue!, parsedValue, operatorSpan);
|
||||
}
|
||||
if (propertyType == typeof(DateTime))
|
||||
{
|
||||
return TryParseValue<DateTime>(valuePart, out var parsedValue) &&
|
||||
Compare((DateTime)propertyValue!, parsedValue, operatorSpan);
|
||||
}
|
||||
if (propertyType == typeof(bool))
|
||||
{
|
||||
return TryParseValue<bool>(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>(T propertyValue, T parsedValue, ReadOnlySpan<char> operatorSpan) where T : INumber<T> =>
|
||||
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<char> originalValue,
|
||||
ReadOnlySpan<char> 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<char> value)
|
||||
{
|
||||
var decimalPlace = value.IndexOf('.');
|
||||
|
|
@ -191,247 +55,4 @@ public static class AdvancedSearchUtilities
|
|||
_ => 1E-16
|
||||
};
|
||||
}
|
||||
|
||||
public static bool Compare(string propertyValue, string parsedValue, ReadOnlySpan<char> 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<char> 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<char> 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<char> operatorSpan) =>
|
||||
operatorSpan switch
|
||||
{
|
||||
"=" or "==" => propertyValue == parsedValue,
|
||||
"!" or "!=" => propertyValue != parsedValue,
|
||||
_ => false
|
||||
};
|
||||
|
||||
public static bool CompareReference<T>(T propertyValue, T parsedValue, ReadOnlySpan<char> 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<T>(ReadOnlySpan<char> 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<long, T>(valuePart, out value);
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(ulong))
|
||||
{
|
||||
return TryParseNumericValue<ulong, T>(valuePart, out value);
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(int))
|
||||
{
|
||||
return TryParseNumericValue<int, T>(valuePart, out value);
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(uint))
|
||||
{
|
||||
return TryParseNumericValue<uint, T>(valuePart, out value);
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(short))
|
||||
{
|
||||
return TryParseNumericValue<short, T>(valuePart, out value);
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(ushort))
|
||||
{
|
||||
return TryParseNumericValue<ushort, T>(valuePart, out value);
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(sbyte))
|
||||
{
|
||||
return TryParseNumericValue<sbyte, T>(valuePart, out value);
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(byte))
|
||||
{
|
||||
return TryParseNumericValue<byte, T>(valuePart, out value);
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(float))
|
||||
{
|
||||
return TryParseNumericValue<float, T>(valuePart, out value);
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(double))
|
||||
{
|
||||
return TryParseNumericValue<double, T>(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<T> — 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<TimeSpan, T>(valuePart, out value);
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(DateTime))
|
||||
{
|
||||
return TryParseSpanParsable<DateTime, T>(valuePart, out value);
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parses U (a value type exposing ISpanParsable<U>) 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<U, T>(ReadOnlySpan<char> valuePart, out T value) where U : ISpanParsable<U>
|
||||
{
|
||||
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<T, R>(ReadOnlySpan<char> valuePart, out R value) where T : INumber<T>
|
||||
{
|
||||
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<char> 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<in TState>(TState state, ReadOnlySpan<char> leaf);
|
||||
|
||||
// OR ('|') binds looser than AND ('@'); split on the outermost OR first, then AND.
|
||||
internal static bool EvaluateBoolean<TState>(ReadOnlySpan<char> expr, TState state, LeafEvaluator<TState> 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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue