ModernUO/dev-docs/generic-commands.md
Kamron Batman c9831b9680
fix: TextDefinition was uneditable in the props gump, and where parsed constants its own way (#2624)
## 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.
2026-09-10 19:36:30 -07:00

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; add order by if the order matters.
  • order by <prop> [direction] [<prop> …]by is optional. Direction is +/up/asc/ascending or -/down/desc/descending, defaulting to ascending. Multiple keys break ties left to right.
  • limit <n> — keeps the first n after the others have run.

Keywords are case-insensitive, and the order you type them does not matter: they always apply as wheredistinctorder bylimit.

[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 where is mandatory; where Movable = true is 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 TextDefinition is always a cliloc. Use @"123" for the string.
  • where … = null and [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 [interface or count first.

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.