## Pressing `>` on a TextDefinition did nothing useful
`#1217` moved `TextDefinition` into the Server project for serialization support and added `[PropertyObject]` along the way. `PropsGump` checks that attribute **before** its parsable fallback, so from that commit on the button either drilled into a read-only `Number`/`String` page (both get-only since `#1221`, so no `>` buttons — a dead end) or, when the value was null, re-sent the same page and looked inert.
Worth being clear that `#765` — which replaced the hand-maintained type list with a generic `IsParsable` branch — was **not** the regression. At that commit `TextDefinition` was `[Parsable]` only, fell through to the new branch, and the editor worked. Only the later attribute hijacked the routing.
`TextDefinition` is now caught ahead of the `[PropertyObject]` branch and routed to `SetGump`, which has had `TextDefinition`-specific code at line 35 all along.
## `0` and `"0"` could not be told apart
`Commands.Split` strips quotes before any parser runs, so this was never fixable in `TextDefinition.Parse` alone — the information is gone two layers earlier. The markers now travel inside the value:
| Input | Result |
|---|---|
| `1060847` | cliloc (unchanged) |
| `#1060847` | cliloc, explicit — the form `ToString()` already writes |
| `@"1060847"` | the literal string |
| `hello` | string (unchanged) |
| `(-null-)` | null (unchanged) |
`GetValue()` quotes a string that would not survive the trip back. That check is a real round trip through the parser rather than a pattern match, so it cannot drift from it. `Types.TryParse` decodes the same escape for plain strings — which is what `[get` has always written for the literal `"null"` without `[set` ever reading it back.
**Saved data is untouched.** Serialization reads a discriminated flag plus int/string (`IGenericReader.cs:175`) and the JSON converter switches on token type; neither calls `Parse`, so a stored string `"1060847"` stays a string and spawner JSON is unchanged. The new syntax stays in the text and command layer, where it reaches `[set`, `[add`, spawner props, the props gump and Advanced Search.
## `where` still parsed its constants its own way
`#2625` carried the hand-rolled constant parser across to `PropertyExpressions.Parse` unchanged. It looks for a static `Parse` overload on the property type and gives up if there is none — and `Type` and `IEntity` have neither:
```
where Subject Kind = Static -> Unable to convert string "Static" into type 'System.Type'.
where Subject Owner = 0x1 -> Unable to convert string "0x1" into type 'Server.Mobile'.
```
Both resolve fine for every other command. Routing through `Types.TryParse` picks up type-name lookup, entity resolution by serial, and the `@"..."` literal convention, so a value that works in one place now works everywhere. It also deletes the duplicate parser (−56 lines in `PropertyExpressions`).
Two things are load-bearing and preserved. `where` has always spelled a null constant as a bare `null`, where `[set` uses `(-null-)`; the shared parser reads a bare `null` as the text, so the null case is handled ahead of it and `= null` keeps meaning null. And nullable targets still unwrap first, so `where <int? prop> = 5` is unaffected.
Bare and hex integers, enums, bools, strings, `Map`, and TextDefinition's `#` / `@"..."` markers all resolve exactly as before — 13 of the 15 tests in `WhereConstantParsingTests` pin that and passed before the change as well as after.
## Documentation
The generic command system had no documentation anywhere in the repo: scopes, `where` and its operators, dot notation, `order by` / `distinct` / `limit`, the value and quoting syntax, `[interface` and `[batch` were all learn-by-reading-the-source. `dev-docs/generic-commands.md` covers them, linked from `CLAUDE.md` and `commands-targeting.md`, and points at <https://muo.gg/commands> for the per-command list rather than duplicating it.
Two behaviours it records that were news to me while writing it:
- `Contained` honours conditions on the normal command path but never sets `SupportsConditionals`, and `[batch` is the only place that reads the flag — so the same condition works typed directly and is refused under batch.
- `Multi`, `Single`, `Self` and `Serial` do not parse modifiers at all, so a `where` clause there is passed to the command as ordinary arguments rather than rejected.
Comments in the changed code were trimmed to what is not evident from the code itself; the rationale they carried is either in the doc now or in the commit that introduced it.
## Behavior changes worth a reviewer's attention
Two things go beyond strict bug-fixing, both deliberate:
- **Hex now parses into a TextDefinition.** Consolidating the three `Parse` overloads onto one span codec means `[set Message 0x102CE7` is cliloc 1060847 rather than the string. Previously only the 1-arg `Parse(string)` did hex and nothing called it. This makes the props gump's own `1060847 (0x102CE7)` display typeable.
- **`@"..."` decodes generally for strings**, not only the exact token `@"null"`. So `[set Name @"hello"` sets `hello`. A half-working escape seemed more surprising than a general one, and the `where` path already decoded it — but narrowing it back is a one-line change if preferred.
## Testing
40 tests, each written first and watched fail for the right reason. They cover the props gump routing (populated and null), cliloc/string/quoted parsing and `GetValue` round trips, `where` constant resolution for Type- and entity-valued properties, and the bare-`null` vs `@"null"` distinction that must not drift.
One sort case is added that #2625 left uncovered: ordering a value-typed chain whose intermediate is null, which reads as `default(int)` rather than throwing. Two other tests from the pre-rebase branch were dropped as duplicates of `ConditionalCompilerEdgeTests`.
Rebased onto `535a09899`. `dotnet build` clean, 0 warnings. **902 UOContent** and **869 Server** tests pass, 0 failures.
## Known gaps
- `Nullable<T>` comparisons work via #2625's lifted operators; nothing here changes that.
- Spawner **Params** are split on plain spaces (`BaseSpawner.cs:1035`) rather than through `Commands.Split`, so a constructor argument still cannot contain one. Untouched here — a tokenizer issue, not a TextDefinition one.
- `Types.ParseStringNumericParamTypes` is now unreferenced. Left in place because it is public and shards may use it.
10 KiB
Generic Commands (finding and manipulating entities)
How staff find a set of objects and run a command against all of them: scopes, where conditions,
order by / distinct / limit, dot notation, value syntax, [interface and [batch.
This covers the generic command system only. For the full per-command list ([add, [props,
[tele, …) see the in-game commands page shipped with the distro, published at
https://muo.gg/commands.
The shape of a generic command
[<scope> <command> [command args] [where <Type> <conditions>] [distinct <props>] [order by <props>] [limit <n>]
- scope — which objects to consider (
Global,Area,Region, …). - command — what to do to each (
Delete,Props,Set,Count,Interface, …). - modifiers — optional filters applied to the found set before the command runs.
[global count where Item Movable = false
[area delete where Item ItemID = 0x1F13
[region interface where Mobile Hits < 10 order by Hits limit 20
Commands opt into which scopes they support and whether they act on Items, Mobiles or both, so not every command works under every scope. A command that does not support the scope reports "That is either an invalid command name or one that does not support this modifier."
Scopes
| Scope | Usage | Conditions? | Selects |
|---|---|---|---|
Global |
[global <command> [condition] |
yes | every object in the world |
Area (Group) |
[area <command> [condition] |
yes | a bounding box you drag |
Screen |
[screen <command> [condition] |
yes | everything on your screen |
Range |
[range <range> <command> [condition] |
yes | within <range> tiles of you |
Region |
[region <command> [condition] |
yes | your current region |
Facet |
[facet <command> [condition] |
yes | your whole map |
Contained |
[contained <command> [condition] |
yes¹ | inside a targeted container |
Online |
[online <command> [condition] |
yes | connected players |
IPAddress |
[ipaddress <command> [condition] |
yes | accounts sharing a targeted player's IP |
Multi (m) |
[m <command> |
no | several objects you target in turn |
Single |
[single <command> |
no | one targeted object |
Self |
[self <command> |
no | you |
Serial |
[serial <serial> <command> |
no | one object by serial |
Multi, Single, Self and Serial do not parse modifiers at all — a where clause there is
not rejected, it is passed through to the command as ordinary arguments.
¹ Contained honours conditions on the normal command path, but it never sets the
SupportsConditionals flag, and [batch is the one place that checks it. So a condition works
under [contained typed directly and is refused under [batch with that scope.
where
The first token after where is a type name, and it is required. It filters the set to objects
of that type (subclasses included) and fixes the type whose properties the rest of the clause reads.
[global count where Item -- every Item
[global count where BaseCreature Hits < 10
Only properties marked [CommandProperty] are visible, and your access level must meet the
attribute's read level.
Operators
| Operator | Meaning |
|---|---|
=, ==, is |
equal |
!= |
not equal |
>, <, >=, <= |
relational (needs a comparable type) |
=~, ~=, ==~, ~==, is~, ~is |
equal, case-insensitive |
!=~, ~!= |
not equal, case-insensitive |
starts, ends, contains |
substring tests |
starts~, ends~, contains~ |
substring tests, case-insensitive |
The ~ may lead or trail — ~contains and contains~ are the same operator.
Relational operators on a type with no ordering (no IComparable) are rejected rather than
silently misbehaving. Equality on such a type compares by value, not by reference.
Combining conditions
Conditions separated by whitespace are ANDed. or (or ||) starts a new alternative group, and
not (or !) negates the single condition that follows it.
[global count where Item Movable = true Hue = 0
[global count where Item Hue = 0 or Hue = 1
[global count where Item not Movable = true
Dot notation
A binding may walk a chain of properties:
[global count where SkillTeleporter Message.Number = 1060847
[global interface where BaseCreature ControlMaster.Name =~ bob
If a link partway along the chain is null, the object simply does not match — it is not an error,
and it does not stop the sweep. The same applies under not: an unreadable binding never matches.
For order by and distinct, which have no "no match" to give, a null link reads as the property
type's default (0, null, …).
Chains are read-only. [set Message.Number 5 fails when the intermediate's members are
get-only, as TextDefinition's are.
Values
A comparison constant is resolved by the same parser behind [set, [add, spawner props and the
props gump, so a value that works in one place works in all of them.
| Form | Means |
|---|---|
123, -4 |
a number |
0x1F13 |
a number, hex |
true / false |
a boolean |
Magery |
an enum member, case-insensitive |
Felucca |
a Map |
Static, BaseCreature |
a Type, by name |
0x40001234 |
an entity, resolved by serial |
hello world |
a string — quote it in the command line if it contains spaces |
null |
null (in where only — see below) |
(-null-) |
null (in [set / [add / spawner props) |
@"text" |
the literal text inside, for values that would otherwise be read as something else |
#1234 |
a TextDefinition cliloc, explicitly |
Quoting, and why @"..." exists
The command tokenizer strips real quotes before any parser sees them, so "0" and 0 arrive
identical. @"..." is the in-band escape that survives:
[set Name @"null" -- the four-letter string, not a null
[set Message @"1060847" -- the string "1060847", not cliloc 1060847
[set Message #1060847 -- cliloc 1060847, explicitly
[set Message 1060847 -- cliloc 1060847 (a bare integer is always a cliloc)
[get writes the same form back for any value that would otherwise be misread, so its output can
be pasted straight into [set.
The one inconsistency: null
where spells a null constant as a bare null. [set and friends use (-null-), and read a bare
null as the four-letter string. This predates the shared parser and is preserved deliberately —
every existing where … = null clause depends on it.
[global count where Item Name = null -- Name is null
[set Name (-null-) -- set Name to null
[set Name null -- set Name to the string "null"
distinct, order by, limit
[global interface where Item order by Hue desc limit 50
[global interface where Mobile distinct Name order by Name
distinct <prop> [<prop> …]— keeps one object per distinct combination of those properties. It sorts internally to do so, so it also reorders the set; addorder byif the order matters.order by <prop> [direction] [<prop> …]—byis optional. Direction is+/up/asc/ascendingor-/down/desc/descending, defaulting to ascending. Multiple keys break ties left to right.limit <n>— keeps the firstnafter the others have run.
Keywords are case-insensitive, and the order you type them does not matter: they always apply
as where → distinct → order by → limit.
[interface
[<scope> interface [view <properties …>] [condition]
Opens a gump listing every match instead of acting on them. Each row can be inspected, and view
adds columns for the properties you name. This is the safest way to see what a condition selects
before running something destructive with the same clause.
[global interface where Item Movable = false ItemID = 0x1F13
[global interface view Hue Name where Mobile Hits < 10
[batch
[batch opens a gump that runs several commands against one found set. Type [batch with no
arguments; the gump has three parts:
- Scope — pick one of the scopes above.
- Condition — the whole clause, and it must start with
where. - Commands — one or more entries, each with a command and an optional Object.
Every command runs against the same matched set, in the order listed. It is the tool for "find
these once, then do three things to them" without re-running an expensive sweep, and for
multi-step edits that would otherwise race against their own filter — a where Hue = 0 clause
re-evaluated after the first command has changed Hue would no longer match.
The Object field is a property chain that redirects that one command onto a sub-object of each
match. Leave it blank to act on the match itself; set it to Backpack to act on each mobile's
backpack instead. Objects whose chain is null or unreadable are skipped for that command.
Logging is suppressed automatically when the set is larger than 20 objects.
Gotchas
- The type after
whereis mandatory;where Movable = trueis a parse error, not a wildcard. - Only
[CommandProperty]members are reachable, and write access is checked separately from read. - Relational operators need a comparable type; equality is available on everything.
- A bare integer targeting a
TextDefinitionis always a cliloc. Use@"123"for the string. where … = nulland[set … (-null-)are different spellings of the same idea. See above.- Spawner Params are split on plain spaces, not the quoting tokenizer, so a constructor argument cannot contain a space. Spawner Props use the normal tokenizer and value syntax.
- Test a destructive clause with
[interfaceorcountfirst.
Key files
| Concern | File |
|---|---|
| Scopes | Projects/UOContent/Commands/Generic/Implementors/ |
where parsing, operators |
Projects/UOContent/Commands/Generic/Implementors/ObjectConditional.cs |
| Condition/sort/distinct compilation | Projects/UOContent/Commands/Generic/Extensions/Compilers/ |
| Modifier parsing and apply order | Projects/UOContent/Commands/Generic/Extensions/BaseExtension.cs |
| Value parsing | Projects/UOContent/Utilities/Types.cs |
| Command tokenizer | Projects/Server/Commands.cs (Commands.Split) |
[batch |
Projects/UOContent/Commands/Batch.cs |
[interface |
Projects/UOContent/Commands/Generic/Commands/Interface.cs |
See also
dev-docs/commands-targeting.md— registering commands and the targeting system.- https://muo.gg/commands — the full command list, regenerated with distro updates.