Difference between revisions of "GMCP"

From LegendMUD
Jump to navigation Jump to search
 
(One intermediate revision by the same user not shown)
Line 39: Line 39:
 
* '''Package names are case-insensitive''' (<code>Char.Score</code> works), and must be dotted — at least <code>word.word</code>.
 
* '''Package names are case-insensitive''' (<code>Char.Score</code> works), and must be dotted — at least <code>word.word</code>.
 
* '''JSON keys are case-sensitive''', in both requests and replies.
 
* '''JSON keys are case-sensitive''', in both requests and replies.
−
* Most packages take no arguments; any request body they receive is ignored. The exceptions are <code>object.info</code> and <code>char.inventory</code> (an <code>oid</code>), <code>help.topic</code> (<code>keywords</code>), and <code>char.journal.entry</code> (<code>vnum</code>).
+
* Most packages take no arguments; any request body they receive is ignored. The exceptions are <code>object.info</code> and <code>char.inventory</code> (an <code>oid</code>), <code>help.topic</code> (<code>keywords</code>), <code>char.journal.entry</code> (<code>vnum</code>), <code>char.skills.query</code> (a <code>slot</code>), <code>comm.delivery.set</code> (a kind → mode object, §4.12), and <code>map.ansi.view</code> / <code>map.ansi.subscribe</code> (a panel size, §4.15).
 
* Requests are size-capped: package name up to 49 characters, JSON body up to 399 characters.
 
* Requests are size-capped: package name up to 49 characters, JSON body up to 399 characters.
 
* A malformed request, unknown package, or invalid JSON gets a '''<code>logging.error</code>''' reply (see §7) rather than silence.
 
* A malformed request, unknown package, or invalid JSON gets a '''<code>logging.error</code>''' reply (see §7) rather than silence.
Line 65: Line 65:
 
room, gold, opponent condition, and everything else the text prompt can
 
room, gold, opponent condition, and everything else the text prompt can
 
show. Immortal-only tokens are omitted for mortals.
 
show. Immortal-only tokens are omitted for mortals.
 +
 +
'''Every token is always present''', whether or not it is in your own
 +
prompt format. You never need another package to read a stat that has a
 +
prompt token — spirit is <code>S5</code>/<code>stat_spirit</code>, your name is <code>n</code>/<code>name</code>, and
 +
so on. <code>char.status</code> is for conditions, affects and timers, not vitals.
 +
 +
The full key list, generated from the server’s token table:
 +
 +
<!-- prompt-keys:begin (generated by docs/coding/gen-gmcp-prompt-keys.py; do not edit by hand) -->
 +
{| class="wikitable"
 +
|-
 +
! Short key
 +
! Long key (<code>gmcplongpromptkeys</code>)
 +
! JSON type
 +
! Meaning
 +
|-
 +
| <code>a</code>
 +
| <code>afk_status</code>
 +
| bool
 +
| AFK status
 +
|-
 +
| <code>A</code>
 +
| <code>align_value</code>
 +
| int
 +
| Alignment
 +
|-
 +
| <code>ak</code>
 +
| <code>area_key</code>
 +
| string
 +
| Area Keyword
 +
|-
 +
| <code>am</code>
 +
| <code>area_maintainer</code>
 +
| string
 +
| Area Maintainer
 +
|-
 +
| <code>an</code>
 +
| <code>area_name</code>
 +
| string
 +
| Area Name
 +
|-
 +
| <code>b</code>
 +
| <code>alignment</code>
 +
| string
 +
| Alignment
 +
|-
 +
| <code>bl</code>
 +
| <code>block</code>
 +
| int
 +
| Block chance
 +
|-
 +
| <code>c</code>
 +
| <code>ac</code>
 +
| int
 +
| Armor rating (ac)
 +
|-
 +
| <code>ch</code>
 +
| <code>chi_current</code>
 +
| int
 +
| Chi (current); present only while martial arts is enabled; absent otherwise
 +
|-
 +
| <code>CH</code>
 +
| <code>chi_max</code>
 +
| int
 +
| Chi (maximum); present only while martial arts is enabled; absent otherwise
 +
|-
 +
| <code>co</code>
 +
| <code>concentration</code>
 +
| int
 +
| Concentration
 +
|-
 +
| <code>d</code>
 +
| <code>dodge</code>
 +
| int
 +
| Dodge chance
 +
|-
 +
| <code>dc</code>
 +
| <code>damcap</code>
 +
| int
 +
| Damage cap
 +
|-
 +
| <code>dr</code>
 +
| <code>damroll</code>
 +
| int
 +
| Damroll
 +
|-
 +
| <code>ds</code>
 +
| <code>damage_shield</code>
 +
| int
 +
| Damage shield
 +
|-
 +
| <code>f</code>
 +
| <code>fighting_name</code>
 +
| string
 +
| Fighting Target
 +
|-
 +
| <code>fc</code>
 +
| <code>fighting_condition</code>
 +
| string
 +
| Target Condition
 +
|-
 +
| <code>ff</code>
 +
| <code>fighting_fighting</code>
 +
| string
 +
| Target’s Target
 +
|-
 +
| <code>fh</code>
 +
| <code>fighting_health</code>
 +
| string
 +
| Target’s Health
 +
|-
 +
| <code>g</code>
 +
| <code>gold</code>
 +
| int
 +
| Gold
 +
|-
 +
| <code>h</code>
 +
| <code>hit_points</code>
 +
| int
 +
| Hit Points (current)
 +
|-
 +
| <code>H</code>
 +
| <code>max_hit_points</code>
 +
| int
 +
| Hit Points (maximum)
 +
|-
 +
| <code>hr</code>
 +
| <code>hitroll</code>
 +
| int
 +
| Hitroll
 +
|-
 +
| <code>ia</code>
 +
| <code>arcane_mastery</code>
 +
| bool
 +
| Arcane Mastery
 +
|-
 +
| <code>k</code>
 +
| <code>afk_tells</code>
 +
| int
 +
| # of AFK messages
 +
|-
 +
| <code>l</code>
 +
| <code>level</code>
 +
| int
 +
| Level
 +
|-
 +
| <code>L</code>
 +
| <code>leader</code>
 +
| string
 +
| Leader
 +
|-
 +
| <code>m</code>
 +
| <code>mana</code>
 +
| int
 +
| Mana (current)
 +
|-
 +
| <code>M</code>
 +
| <code>max_mana</code>
 +
| int
 +
| Mana (maximum)
 +
|-
 +
| <code>mc</code>
 +
| <code>combat_mood</code>
 +
| string
 +
| Combat mood
 +
|-
 +
| <code>mi</code>
 +
| <code>mitigation</code>
 +
| int
 +
| Mitigation
 +
|-
 +
| <code>mr</code>
 +
| <code>mana_reduction</code>
 +
| int
 +
| Mana Reduction
 +
|-
 +
| <code>ms</code>
 +
| <code>social_mood</code>
 +
| string
 +
| Socials mood
 +
|-
 +
| <code>mt</code>
 +
| <code>temporary_mood</code>
 +
| string
 +
| Talk mood
 +
|-
 +
| <code>mw</code>
 +
| <code>walk_mood</code>
 +
| string
 +
| Walk mood
 +
|-
 +
| <code>n</code>
 +
| <code>name</code>
 +
| string
 +
| Character Name
 +
|-
 +
| <code>p</code>
 +
| <code>position</code>
 +
| string
 +
| Position
 +
|-
 +
| <code>P</code>
 +
| <code>pk_damage</code>
 +
| int
 +
| PK damage
 +
|-
 +
| <code>pa</code>
 +
| <code>parry</code>
 +
| int
 +
| Parry Bonus
 +
|-
 +
| <code>pr</code>
 +
| <code>prestige</code>
 +
| int
 +
| Prestige
 +
|-
 +
| <code>v</code>
 +
| <code>move</code>
 +
| int
 +
| Move (current)
 +
|-
 +
| <code>V</code>
 +
| <code>max_move</code>
 +
| int
 +
| Move (maximum)
 +
|-
 +
| <code>vi</code>
 +
| <code>area_percent_explored</code>
 +
| number
 +
| Visited Info
 +
|-
 +
| <code>ra</code>
 +
| <code>ranged_accuracy</code>
 +
| int
 +
| Ranged Accuracy
 +
|-
 +
| <code>rc</code>
 +
| <code>current_rent</code>
 +
| int
 +
| Current Rent
 +
|-
 +
| <code>rg</code>
 +
| <code>rage</code>
 +
| int
 +
| Rage
 +
|-
 +
| <code>rm</code>
 +
| <code>max_rent</code>
 +
| int
 +
| Max Rent
 +
|-
 +
| <code>rs</code>
 +
| <code>rent_status</code>
 +
| string
 +
| Rent Status (under/over)
 +
|-
 +
| <code>rf</code>
 +
| <code>rent_free</code>
 +
| int
 +
| Free Rent
 +
|-
 +
| <code>sc</code>
 +
| <code>spell_crit</code>
 +
| int
 +
| Spell Crit
 +
|-
 +
| <code>sd</code>
 +
| <code>spell_damroll</code>
 +
| int
 +
| Spell Damroll
 +
|-
 +
| <code>R0</code>
 +
| <code>raw_strength</code>
 +
| int
 +
| Raw Strength
 +
|-
 +
| <code>R1</code>
 +
| <code>raw_mind</code>
 +
| int
 +
| Raw Mind
 +
|-
 +
| <code>R2</code>
 +
| <code>raw_dexterity</code>
 +
| int
 +
| Raw Dexterity
 +
|-
 +
| <code>R3</code>
 +
| <code>raw_constitution</code>
 +
| int
 +
| Raw Constitution
 +
|-
 +
| <code>R4</code>
 +
| <code>raw_perception</code>
 +
| int
 +
| Raw Perception
 +
|-
 +
| <code>R5</code>
 +
| <code>raw_spirit</code>
 +
| int
 +
| Raw Spirit
 +
|-
 +
| <code>S0</code>
 +
| <code>stat_strength</code>
 +
| int
 +
| Stat Strength
 +
|-
 +
| <code>S1</code>
 +
| <code>stat_mind</code>
 +
| int
 +
| Stat Mind
 +
|-
 +
| <code>S2</code>
 +
| <code>stat_dexterity</code>
 +
| int
 +
| Stat Dexterity
 +
|-
 +
| <code>S3</code>
 +
| <code>stat_constitution</code>
 +
| int
 +
| Stat Constitution
 +
|-
 +
| <code>S4</code>
 +
| <code>stat_perception</code>
 +
| int
 +
| Stat Perception
 +
|-
 +
| <code>S5</code>
 +
| <code>stat_spirit</code>
 +
| int
 +
| Stat Spirit
 +
|-
 +
| <code>t</code>
 +
| <code>time</code>
 +
| string
 +
| Game Time
 +
|-
 +
| <code>T</code>
 +
| <code>system_time</code>
 +
| string
 +
| System Time
 +
|-
 +
| <code>w</code>
 +
| <code>wimpy</code>
 +
| int
 +
| Wimpy
 +
|-
 +
| <code>W</code>
 +
| <code>wary</code>
 +
| int
 +
| Agg/Wary
 +
|-
 +
| <code>wc</code>
 +
| <code>weight</code>
 +
| string
 +
| Current Weight
 +
|-
 +
| <code>wm</code>
 +
| <code>max_weight</code>
 +
| string
 +
| Maximum Weight
 +
|-
 +
| <code>wt</code>
 +
| <code>wait</code>
 +
| int
 +
| Current Wait
 +
|-
 +
| <code>Wh</code>
 +
| <code>hp_watching</code>
 +
| int
 +
| Hit Point WATCH target’s HP
 +
|-
 +
| <code>Wm</code>
 +
| <code>mana_watching</code>
 +
| int
 +
| Mana WATCH target’s MANA
 +
|-
 +
| <code>Wv</code>
 +
| <code>move_watching</code>
 +
| int
 +
| Move WATCH target’s MOVE
 +
|-
 +
| <code>WH</code>
 +
| <code>watching_hp</code>
 +
| string
 +
| Hit Point WATCH target
 +
|-
 +
| <code>WM</code>
 +
| <code>watching_mana</code>
 +
| string
 +
| Mana WATCH target
 +
|-
 +
| <code>WV</code>
 +
| <code>watching_move</code>
 +
| string
 +
| Move WATCH target
 +
|-
 +
| <code>x</code>
 +
| <code>exp</code>
 +
| int
 +
| Experience (current)
 +
|-
 +
| <code>X</code>
 +
| <code>xp_to_level</code>
 +
| int
 +
| Experience to Next Level
 +
|-
 +
| <code>1</code>
 +
| <code>percent_hp</code>
 +
| int
 +
| Hit Points (percentage)
 +
|-
 +
| <code>2</code>
 +
| <code>percent_mana</code>
 +
| int
 +
| Mana (percentage)
 +
|-
 +
| <code>3</code>
 +
| <code>percent_move</code>
 +
| int
 +
| Move (percentage)
 +
|-
 +
| <code>4</code>
 +
| <code>percent_xp</code>
 +
| int
 +
| Experience to Next Level (percentage)
 +
|-
 +
| <code>5</code>
 +
| <code>era_exp_curr</code>
 +
| int
 +
| Era Experience (in current era)
 +
|-
 +
| <code>6</code>
 +
| <code>era_exp_to_level</code>
 +
| int
 +
| Era Experience to Next Era Level
 +
|-
 +
| <code>$</code>
 +
| <code>newline</code>
 +
| string
 +
| Adds a line feed into the prompt; prompt-format control token; carried for completeness
 +
|-
 +
| <code>@</code>
 +
| <code>at</code>
 +
| string
 +
| A literal ‘@’; prompt-format control token; carried for completeness
 +
|-
 +
| <code>!</code>
 +
| <code>mail</code>
 +
| string
 +
| MAIL if you have mail waiting
 +
|-
 +
| <code>#0</code>
 +
| <code>timer_0</code>
 +
| string
 +
| Timer with the shortest duration
 +
|-
 +
| <code>#1</code>
 +
| <code>timer_1</code>
 +
| string
 +
| Timer with the second shortest duration
 +
|-
 +
| <code>#2</code>
 +
| <code>timer_2</code>
 +
| string
 +
| Timer with the third shortest duration
 +
|-
 +
| <code>e</code>
 +
| <code>era</code>
 +
| string
 +
| Era
 +
|-
 +
| <code>i</code>
 +
| <code>wizinvis_status</code>
 +
| string
 +
| Wizinvis Status; immortals only, absent for mortals
 +
|-
 +
| <code>r</code>
 +
| <code>room</code>
 +
| int
 +
| Room Vnum; immortals only, absent for mortals
 +
|-
 +
| <code>y</code>
 +
| <code>yellzone</code>
 +
| int
 +
| Yellzone; immortals only, absent for mortals
 +
|}
 +
 +
<!-- prompt-keys:end -->
 +
Chi is among them: the <code>chi_current</code>/<code>chi_max</code> tokens (short codes <code>ch</code>
 +
and <code>CH</code>) are ordinary mortal tokens. They are not in the default text
 +
prompt — a player adds <code>@ch</code>/<code>@CH</code> to their own prompt format to see them
 +
there. '''They are present on <code>char.prompt</code>/<code>char.prompt.delta</code> only when
 +
the martial arts system is enabled on this server; while it is off, both
 +
keys are absent entirely''' (not sent as <code>0</code> or <code>null</code>), the same
 +
present-only-when-enabled rule <code>char.score.vitals.chi</code>/<code>vitals.maxChi</code>
 +
(§4.1 below) already follows. Clients must tolerate their absence —
 +
check for the key before reading it, don’t assume it’s always there.
  
 
By default the keys are the short prompt codes. Turn on the
 
By default the keys are the short prompt codes. Turn on the
Line 125: Line 622:
 
   },
 
   },
 
   "vitals":  { "hp": 180, "maxHp": 250, "mana": 400, "maxMana": 500,
 
   "vitals":  { "hp": 180, "maxHp": 250, "mana": 400, "maxMana": 500,
−
               "move": 120, "maxMove": 150 },
+
               "move": 120, "maxMove": 150, "chi": 30, "maxChi": 50 },
 
   "stats":  { "str": 18, "min": 16, "dex": 17, "con": 16, "per": 14,
 
   "stats":  { "str": 18, "min": 16, "dex": 17, "con": 16, "per": 14,
 
               "spi": 15, "prestige": 2 },
 
               "spi": 15, "prestige": 2 },
Line 159: Line 656:
 
<code>&quot;PKOK&quot;</code>, <code>&quot;PKE, PKOK&quot;</code>, or <code>&quot;none&quot;</code>. <code>resistances</code> has one entry per
 
<code>&quot;PKOK&quot;</code>, <code>&quot;PKE, PKOK&quot;</code>, or <code>&quot;none&quot;</code>. <code>resistances</code> has one entry per
 
nonzero damage modifier; empty array when you have none.
 
nonzero damage modifier; empty array when you have none.
 +
 +
<code>vitals.chi</code>/<code>vitals.maxChi</code> are present only when the martial arts system
 +
is enabled on this server; while it is off they are omitted entirely (not
 +
<code>null</code>), so <code>vitals</code> has 6 members instead of 8. Clients must tolerate
 +
their absence rather than assume the keys always exist. The sample above
 +
assumes the system is enabled; with it off, drop <code>chi</code>/<code>maxChi</code> from the
 +
<code>vitals</code> object you’d expect to receive.
  
 
Conditions, affects, and timers are deliberately ''not'' here — see
 
Conditions, affects, and timers are deliberately ''not'' here — see
Line 165: Line 669:
 
==== <code>char.status</code> — request-only ====
 
==== <code>char.status</code> — request-only ====
  
−
<code>{ conditions, affectedBy, timers }</code>, each an array or <code>null</code>. Three
+
<code>{ conditions, affectedBy, timers, charmies }</code>, each an array or <code>null</code>.
−
sub-packages return one slice each, in the same shapes:
+
Three sub-packages return one slice each, in the same shapes (none of
 +
them includes <code>charmies</code>):
  
 
* <code>char.status.conditions</code> → <code>{ &quot;conditions&quot;: [&quot;hungry&quot;, …] | null }</code>
 
* <code>char.status.conditions</code> → <code>{ &quot;conditions&quot;: [&quot;hungry&quot;, …] | null }</code>
Line 189: Line 694:
 
   "susceptibilities": [ "cold" ]
 
   "susceptibilities": [ "cold" ]
 
}</pre>
 
}</pre>
−
* <code>disposition</code> — always one of <code>&quot;beneficial&quot;</code>, <code>&quot;detrimental&quot;</code>, or <code>&quot;neutral&quot;</code>; how the game classifies the affect (the same classification that colors the affect’s name green/red in the text affects list). If an affect somehow carries both classifications, <code>detrimental</code> wins. Exactly these three strings — but treat an unknown value as <code>neutral</code> rather than erroring, per the usual forward-compatibility rule. Beneficial affects are player-removable: a UI may offer a remove control on <code>&quot;beneficial&quot;</code> entries that sends <code>removeaffect &lt;name&gt;</code> (the entry’s <code>name</code> verbatim) — word the player-facing confirmation <code>Remove &lt;name&gt;?</code>, not the command name. Don’t offer it on detrimental/neutral entries; the server refuses those.
+
<ul>
−
* <code>applies</code> — object, numeric stat modifiers this affect currently contributes. Keys are stat names (<code>strength</code>, <code>saving_spell</code>, <code>hp_regen</code>, <code>ac</code>, …); values are signed JSON ints (negative values are normal and mean the same thing they do everywhere else — e.g. lower <code>ac</code> is better).
+
<li><p><code>disposition</code> — always one of <code>&quot;beneficial&quot;</code>, <code>&quot;detrimental&quot;</code>, or
−
* <code>grants</code> — array of flag names this affect grants (e.g. <code>&quot;sanctuary&quot;</code>, <code>&quot;detect_invis&quot;</code>).
+
<code>&quot;neutral&quot;</code>; how the game classifies the affect (the same
−
* <code>dmgMods</code> — object of damage-type → signed percent modifier, e.g. <code>{ &quot;slash&quot;: 10 }</code> means +10% slash damage. Its damage-type names heavily overlap with <code>resists</code> / <code>immunities</code> / <code>susceptibilities</code> (<code>flame</code>, <code>poison</code>, <code>slash</code>, …) but the sets are '''not identical''' — each key’s vocabulary comes from its own server table. Don’t build one fixed shared list; treat each key’s names as its own open vocabulary and ignore unknowns.
+
classification that colors the affect’s name green/red in the text
−
* <code>resists</code> / <code>immunities</code> / <code>susceptibilities</code> — arrays of damage-type names this affect resists, grants immunity to, or makes the character more susceptible to.
+
affects list). If an affect somehow carries both classifications,
−
* All key/value vocabularies are mechanical lowercase of the game’s internal names — the same information <code>STATUS full</code> shows a player for that affect, just structured instead of prose. Key names are the server’s mechanical internal names, which sometimes differ from the friendly labels <code>STATUS</code> prints (e.g., <code>mod_buf_hitroll</code> is the HITROLL line). This is forward-compatible: new names can appear with no protocol change, so ignore any key or value you don’t recognize rather than treating it as an error.
+
<code>detrimental</code> wins. Exactly these three strings — but treat an
−
* If an affect contributes to the same stat/damage-type more than once (e.g. two stacked sources folded into one entry), the value you receive is already the summed total — you never need to add entries together yourself.
+
unknown value as <code>neutral</code> rather than erroring, per the usual
−
* <code>char.status.timers</code> → <code>{ &quot;timers&quot;: [{ &quot;name&quot;, &quot;time&quot; }, …] | null }</code> — skill/ability reuse timers.
+
forward-compatibility rule. Beneficial affects are player-removable:
 +
a UI may offer a remove control on <code>&quot;beneficial&quot;</code> entries that sends
 +
<code>removeaffect &lt;name&gt;</code> (the entry’s <code>name</code> verbatim) — word the
 +
player-facing confirmation <code>Remove &lt;name&gt;?</code>, not the command name.
 +
Don’t offer it on detrimental/neutral entries; the server refuses
 +
those.</p></li>
 +
<li><p><code>applies</code> — object, numeric stat modifiers this affect currently
 +
contributes. Keys are stat names (<code>strength</code>, <code>saving_spell</code>,
 +
<code>hp_regen</code>, <code>ac</code>, …); values are signed JSON ints (negative values
 +
are normal and mean the same thing they do everywhere else — e.g.
 +
lower <code>ac</code> is better).</p></li>
 +
<li><p><code>grants</code> — array of flag names this affect grants (e.g.
 +
<code>&quot;sanctuary&quot;</code>, <code>&quot;detect_invis&quot;</code>).</p></li>
 +
<li><p><code>dmgMods</code> — object of damage-type → signed percent modifier, e.g.
 +
<code>{ &quot;slash&quot;: 10 }</code> means +10% slash damage. Its damage-type names
 +
heavily overlap with <code>resists</code> / <code>immunities</code> / <code>susceptibilities</code>
 +
(<code>flame</code>, <code>poison</code>, <code>slash</code>, …) but the sets are '''not identical''' —
 +
each key’s vocabulary comes from its own server table. Don’t build
 +
one fixed shared list; treat each key’s names as its own open
 +
vocabulary and ignore unknowns.</p></li>
 +
<li><p><code>resists</code> / <code>immunities</code> / <code>susceptibilities</code> — arrays of
 +
damage-type names this affect resists, grants immunity to, or makes
 +
the character more susceptible to.</p></li>
 +
<li><p>All key/value vocabularies are mechanical lowercase of the game’s
 +
internal names — the same information <code>STATUS full</code> shows a player for
 +
that affect, just structured instead of prose. Key names are the
 +
server’s mechanical internal names, which sometimes differ from the
 +
friendly labels <code>STATUS</code> prints (e.g., <code>mod_buf_hitroll</code> is the
 +
HITROLL line). This is forward-compatible: new names can appear with
 +
no protocol change, so ignore any key or value you don’t recognize
 +
rather than treating it as an error.</p></li>
 +
<li><p>If an affect contributes to the same stat/damage-type more than
 +
once (e.g. two stacked sources folded into one entry), the value
 +
you receive is already the summed total — you never need to add
 +
entries together yourself.</p></li>
 +
<li><p><code>char.status.timers</code> → <code>{ &quot;timers&quot;: [{ &quot;name&quot;, &quot;time&quot; }, …] | null }</code>
 +
— skill/ability reuse timers.</p></li>
 +
<li><p><code>charmies</code> (in the composite only — there is no sub-package for it) →
 +
one entry per charmed pet standing in your room, or <code>null</code> when none
 +
is with you. You only get a pet’s status while it is in your room,
 +
matching the in-game limitation; a pet elsewhere simply drops out of
 +
the array, so treat absence as “not visible”, not “gone”. Entry shape:</p>
 +
<pre>{
 +
  "name": "guard dog pet",
 +
  "longName": "a guard dog",
 +
  "conditions": [ "hungry" ],
 +
  "affectedBy": [ { "time": 512, "name": "armor", "disposition": "beneficial" } ],
 +
  "timers": null
 +
}</pre>
 +
<p><code>name</code> is the same keyword string <code>char.group</code> members carry — use it
 +
to correlate the two packages. <code>longName</code> is the display name.
 +
<code>conditions</code>, <code>affectedBy</code>, and <code>timers</code> have exactly the shapes
 +
documented above for the player’s own slices (including the affect
 +
payload keys and <code>null</code> when empty).</p></li></ul>
  
 
==== <code>char.offer</code> — request-only ====
 
==== <code>char.offer</code> — request-only ====
Line 241: Line 799:
 
If <code>max &lt;= 0</code> or the values are hidden, the mud shows <code>??</code> uncolored —
 
If <code>max &lt;= 0</code> or the values are hidden, the mud shows <code>??</code> uncolored —
 
fall back to your untinted rendering rather than guessing a tier.
 
fall back to your untinted rendering rather than guessing a tier.
−
Full contract: <code>docs/updates/2026-07-15-gmcp-char-colors-client-spec.md</code>.
+
 
 +
<code>char.colors</code> also carries a '''<code>&quot;channel&quot;</code> group''', alongside
 +
<code>&quot;condition&quot;</code>, at the same top level:
 +
 
 +
<pre>{ "condition": { ... },
 +
  "channel": {
 +
    "bracket": { "fg": 7,  "bg": -1, "attrs": [] },
 +
    "name":    { "fg": 5,  "bg": -1, "attrs": [] },
 +
    "speaker": { "fg": 12, "bg": -1, "attrs": [] },
 +
    "text":    { "fg": 5,  "bg": -1, "attrs": [] }
 +
  }
 +
}</pre>
 +
Four entries, the same <code>{fg, bg, attrs}</code> shape as <code>condition</code>’s groups
 +
— the player’s standard channel palette: <code>bracket</code> (the <code>[ ]</code> around
 +
the channel name, and the speaker colon), <code>name</code> (the channel name
 +
inside the brackets), <code>speaker</code> (the speaking character’s name), and
 +
<code>text</code> (the message body). Same delivery as the rest of <code>char.colors</code>:
 +
full snapshot at login/reconnect, on request, and pushed automatically
 +
whenever the player reconfigures any of these four colors in-game.
 +
 
 +
'''Advisory, not authoritative.''' These are the colors of the STANDARD
 +
channel format’s slots (the ones the default format, and the
 +
<code>@C</code>/<code>@N</code>/<code>@M</code> macros a custom format can use, hardcode). A player
 +
running a fully hand-rolled custom <code>chan_format</code> that skips those
 +
macros may render channel messages differently. The palette is for
 +
'''your own UI chrome''' — a channel list, tab colors, a compose box —
 +
never to re-render <code>comm.message</code>’s <code>line</code>, which stays the exact
 +
rendering ground truth for every subscriber regardless of this
 +
group’s values.
 +
 
 +
Full contract: <code>docs/updates/2026-07-15-gmcp-char-colors-client-spec.md</code>
 +
(condition) and
 +
<code>docs/updates/2026-08-06-gmcp-channel-catalog-handover.md</code> (channel
 +
group and <code>comm.channels</code>, below).
 +
 
 +
<code>char.colors</code> also carries a '''<code>&quot;slots&quot;</code> group''', a third top-level
 +
member alongside <code>&quot;condition&quot;</code> and <code>&quot;channel&quot;</code>: the player’s entire
 +
color table, all 58 u-color slots, keyed by the wire’s own u-code
 +
numbers as unpadded decimal strings (<code>&quot;0&quot;</code> .. <code>&quot;57&quot;</code>, matching how you
 +
already parse <code>\|U7</code>/<code>\|U10</code> numerically out of a <code>line</code>). Values are
 +
the same resolved <code>{fg, bg, attrs}</code> shape as <code>condition</code> and <code>channel</code>.
 +
 
 +
<pre>{ "condition": { ... },
 +
  "channel": { ... },
 +
  "slots": {
 +
    "0":  { "fg": 8,  "bg": -1, "attrs": [] },
 +
    "1":  { "fg": 9,  "bg": -1, "attrs": [] },
 +
    "7":  { "fg": 8,  "bg": -1, "attrs": [] },
 +
    "8":  { "fg": 5,  "bg": -1, "attrs": [] },
 +
    "9":  { "fg": 12, "bg": -1, "attrs": [] },
 +
    "10": { "fg": 5,  "bg": -1, "attrs": [] },
 +
    ...
 +
    "57": { "fg": 1,  "bg": -1, "attrs": [] }
 +
  }
 +
}</pre>
 +
This is the join key the <code>channel</code>/<code>condition</code> groups don’t carry:
 +
<code>slots[&quot;7&quot;]</code> .. <code>slots[&quot;10&quot;]</code> are value-identical to
 +
<code>channel.bracket</code>/<code>name</code>/<code>speaker</code>/<code>text</code>, and <code>slots[&quot;16&quot;]</code> ..
 +
<code>slots[&quot;20&quot;]</code> are value-identical to <code>condition.full</code>/<code>low</code>/<code>medium</code>/
 +
<code>bad</code>/<code>critical</code> (verified byte-equal on the wire). Use <code>slots</code> to
 +
color every <code>\|Uxx</code> marker your own <code>line</code>-parsing turns up, including
 +
markers a hand-rolled CHANFORMAT emits that never show up in the
 +
<code>channel</code> group at all — <code>condition</code>/<code>channel</code> stay the semantic
 +
labels for their four/five familiar roles; <code>slots</code> is the general
 +
lookup table underneath them.
 +
 
 +
'''Semantics pinned for this group:'''
 +
 
 +
* '''Delivery.''' Same as the rest of <code>char.colors</code>: a full snapshot at login/reconnect, on request, and re-pushed whole (coalesced, at most once per pulse) whenever the player changes any color slot. Replace your copy wholesale on every message; never diff or merge.
 +
* '''All 58 slots are always present, from this server.''' Every slot always has a compiled color (defaults fill any slot the player never touched), so an absent slot cannot occur talking to this server. An absent slot means “no opinion, render that segment plain” — that fallback exists for older servers that don’t send <code>slots</code> at all, not for anything this server can produce.
 +
* '''Slot numbers are stable.''' They’re a compile-time, append-only enum on the server — stable within a session, stable across sessions, stable across characters — and change only when the mud itself ships a new release that appends a slot. Keep replacing on every snapshot regardless; this only affects how hard you lean on caching between snapshots.
 +
* '''Advisory, same doctrine as <code>channel</code>.''' <code>line</code> is the ground truth for exact rendering. Where a <code>comm.message</code> frame carries its own <code>color</code> member, that member stays authoritative for its segment; <code>slots</code> fills in everything <code>color</code> doesn’t cover.
  
 
=== 4.2 Room and world ===
 
=== 4.2 Room and world ===
Line 249: Line 878:
 
<pre>{ "name": "...", "desc": "...", "area": "...", "vnum": 3001,
 
<pre>{ "name": "...", "desc": "...", "area": "...", "vnum": 3001,
 
   "type": "indoors", "is_inn": false,
 
   "type": "indoors", "is_inn": false,
 +
  "echo": { "zone": "cave", "room": "small_indoors" },
 
   "exits": [ { "dir": "north", "door": "closed",
 
   "exits": [ { "dir": "north", "door": "closed",
 
               "to_name": "A Quiet Lane", "to_vnum": 3005 }, … ] }</pre>
 
               "to_name": "A Quiet Lane", "to_vnum": 3005 }, … ] }</pre>
−
* '''Darkness''': if your character can’t see, <code>name</code> and <code>desc</code> are both <code>&quot;It is too dark to see...&quot;</code> and <code>exits</code> is omitted entirely.
+
<ul>
−
* '''Blindness''': <code>exits</code> is omitted while blind, whatever the light.
+
<li><p>'''Darkness''': if your character can’t see, <code>name</code> and <code>desc</code> are both
−
* <code>area</code> is present only when the room belongs to a known area.
+
<code>&quot;It is too dark to see...&quot;</code> and <code>exits</code> is omitted entirely.</p></li>
−
* <code>vnum</code> is present for every room ''except'' rooms inside transient instances, whose recycled vnums are meaningless and suppressed. The same applies to <code>to_vnum</code> on exits. Don’t key persistent mapper data on instance rooms.
+
<li><p>'''Blindness''': <code>exits</code> is omitted while blind, whatever the light.</p></li>
−
* <code>type</code> is always present: one of <code>&quot;indoors&quot;</code>, <code>&quot;underwater&quot;</code>, <code>&quot;aerial&quot;</code>, <code>&quot;water&quot;</code>, <code>&quot;outdoors&quot;</code>.
+
<li><p><code>area</code> is present only when the room belongs to a known area.</p></li>
−
* <code>is_inn</code> is always present and is '''character-dependent''': whether ''you'' could rent here ''right now'' (false while fighting, for example). It is not a fixed property of the room. Immortals always see <code>false</code>.
+
<li><p><code>vnum</code> is present for every room. Inside an instance it is the live
−
* <code>exits</code> mirrors the in-game autoexit line, including its secrecy rules — hidden or unrevealed exits simply don’t appear. Per exit: <code>dir</code> always; <code>door</code> only for door exits (<code>&quot;open&quot;</code>, <code>&quot;closed&quot;</code>, or <code>&quot;locked&quot;</code>); <code>to_name</code> only when the destination’s name is visible.
+
slot vnum, always in 85000-89999; that range is how you tell an
 +
instance room from a world room. Slot vnums are recycled between
 +
openings, so never key a saved map on one.</p></li>
 +
<li><p><code>instance</code> is present only inside a live instance:
 +
<code>{ &quot;id&quot;: &quot;62a86aa50d3c3c8c&quot;, &quot;room&quot;: 13314, &quot;name&quot;: &quot;Maharaurava&quot; }</code>.
 +
<code>id</code> is an opaque string, unique to this opening and never reused.
 +
<code>room</code> is the origin vnum in the source block the instance was copied
 +
from. <code>name</code> is the builder’s name for the instance and is omitted
 +
when unset. Key a saved instance map on <code>name</code>, with rooms keyed on
 +
<code>room</code>; when there is no <code>name</code>, map the instance for the session
 +
only and drop it when <code>id</code> changes or the block disappears. The
 +
block appearing is your entry signal; the eviction look, which
 +
carries no block, is your exit signal. There is no separate push.</p></li>
 +
<li><p>On exits, <code>to_vnum</code> is always present, slot vnums included. When the
 +
destination is a live instance room the exit also carries <code>to_inst</code>,
 +
the destination’s origin vnum, so you can draw the edge in source
 +
space before walking it. An exit that leaves the instance has a real
 +
<code>to_vnum</code> and no <code>to_inst</code>.</p></li>
 +
<li><p><code>random_stamp</code> (integer, epoch seconds) is present only on rooms the
 +
game’s random map generator has touched since boot. Its exits are
 +
rewired each time the generator runs, so hold such a room in a
 +
session-only map layer, never in the saved map. Every room of one
 +
generator run carries the same value; when a room arrives with a
 +
different stamp than you hold for it, drop that room’s cached edges
 +
(and, optionally, those of every room sharing the old stamp) and
 +
rebuild from the live <code>exits</code>. Absent field = static room.</p></li>
 +
<li><p><code>type</code> is always present: one of <code>&quot;indoors&quot;</code>, <code>&quot;underwater&quot;</code>,
 +
<code>&quot;aerial&quot;</code>, <code>&quot;water&quot;</code>, <code>&quot;outdoors&quot;</code>.</p></li>
 +
<li><p><code>is_inn</code> is always present and is '''character-dependent''': whether
 +
''you'' could rent here ''right now'' (false while fighting, for
 +
example). It is not a fixed property of the room. Immortals always
 +
see <code>false</code>.</p></li>
 +
<li><p><code>exits</code> mirrors the in-game autoexit line, including its secrecy
 +
rules — hidden or unrevealed exits simply don’t appear. Per exit:
 +
<code>dir</code> always; <code>door</code> only for door exits (<code>&quot;open&quot;</code>, <code>&quot;closed&quot;</code>, or
 +
<code>&quot;locked&quot;</code>); <code>to_name</code> only when the destination’s name is visible.</p></li>
 +
<li><p><code>echo</code> is present only when a builder has described the room’s
 +
acoustics, as an object with up to two string members: <code>zone</code>, the word
 +
for the room’s yellzone, and <code>room</code>, the word for the room itself. Use
 +
<code>room</code> when present, else <code>zone</code>, else treat the room as <code>none</code>. An
 +
absent member is <code>none</code>. The words:</p>
 +
{| class="wikitable"
 +
|-
 +
! word
 +
! meaning
 +
|-
 +
| <code>none</code>
 +
| dry, no processing; also what an unset level means
 +
|-
 +
| <code>small_indoors</code>
 +
| a room, tavern, hut
 +
|-
 +
| <code>large_indoors</code>
 +
| a big interior, warehouse, temple nave
 +
|-
 +
| <code>hall</code>
 +
| stone cathedral scale, long bright tail
 +
|-
 +
| <code>cave</code>
 +
| long dark tail
 +
|-
 +
| <code>outdoor_small</code>
 +
| alley, courtyard, forest clearing
 +
|-
 +
| <code>outdoor_large</code>
 +
| open field, plain, sea
 +
|-
 +
| <code>underwater</code>
 +
| muffled, the one that filters the dry signal too
 +
|}
 +
 
 +
<p>The member rides every <code>room.info</code> outside an instance, the dark form
 +
included (instance rooms never carry it); what a word
 +
sounds like is yours. Apply it to the Client.Media sound half; music is
 +
usually left dry.</p></li></ul>
  
 
=== 4.3 Objects, inventory, equipment ===
 
=== 4.3 Objects, inventory, equipment ===
Line 275: Line 979:
 
<code>oid</code>. When found and visible:
 
<code>oid</code>. When found and visible:
  
−
* Core fields: <code>oid</code>, <code>name</code> (keywords), <code>short</code>, <code>desc</code>, <code>type</code> (item-type name), <code>weight</code>, <code>rent</code>, <code>size</code>, <code>ac</code>, <code>timer</code>.
+
* Core fields: <code>oid</code>, <code>name</code> (keywords), <code>short</code>, <code>desc</code>, <code>type</code> (item-type name), <code>weight</code>, <code>rent</code>, <code>size</code>, <code>ac</code>, <code>timer</code>. <code>timer</code> is the ticks until the item decays, <code>-1</code> for a permanent item; a tick is 90 real seconds and two ticks make a mud hour, so do not label it as hours. Every change to it reaches you through <code>char.items.update</code> (section on pushes), so a countdown can be re-synced from each push.
 
* <code>condition: { &quot;dam&quot;, &quot;damMax&quot; }</code> — only when the item has a damage / repair ceiling.
 
* <code>condition: { &quot;dam&quot;, &quot;damMax&quot; }</code> — only when the item has a damage / repair ceiling.
 
* <code>flags[]</code> — every item flag set on the object, present only when at least one is set. Names are the lowercase flag words: <code>&quot;glow&quot;</code>, <code>&quot;magic&quot;</code>, <code>&quot;invis&quot;</code> (the item is invis but you can see it anyway — style it accordingly), <code>&quot;no_repair&quot;</code>, <code>&quot;no_backstab&quot;</code>, <code>&quot;unique&quot;</code>, and so on. New flags appear automatically as the game adds them, so ignore names you don’t recognize.
 
* <code>flags[]</code> — every item flag set on the object, present only when at least one is set. Names are the lowercase flag words: <code>&quot;glow&quot;</code>, <code>&quot;magic&quot;</code>, <code>&quot;invis&quot;</code> (the item is invis but you can see it anyway — style it accordingly), <code>&quot;no_repair&quot;</code>, <code>&quot;no_backstab&quot;</code>, <code>&quot;unique&quot;</code>, and so on. New flags appear automatically as the game adds them, so ignore names you don’t recognize.
Line 287: Line 991:
 
** Item types without an interpreted block (decorative/misc items, and a few whose values are internal state) simply omit it.
 
** Item types without an interpreted block (decorative/misc items, and a few whose values are internal state) simply omit it.
 
* <code>affects[]</code> — <code>[{ &quot;stat&quot;, &quot;mod&quot; }, …]</code>, plus <code>{ &quot;stat&quot;: &quot;dmgmod&quot;, &quot;mod&quot;, &quot;pct&quot; }</code> entries for damage-modifier affects.
 
* <code>affects[]</code> — <code>[{ &quot;stat&quot;, &quot;mod&quot; }, …]</code>, plus <code>{ &quot;stat&quot;: &quot;dmgmod&quot;, &quot;mod&quot;, &quot;pct&quot; }</code> entries for damage-modifier affects.
−
* <code>props{}</code> — the object’s key/value property table, when it has one.
+
* <code>use{}</code> — only on an item that can be USEd for a spell: <code>{ &quot;spell&quot;, &quot;spellName&quot;, &quot;recycleInterval&quot;, &quot;recycleLeft&quot;, &quot;wear&quot;, &quot;selfOnly&quot; }</code>. <code>spellName</code> is the printable name; <code>spell</code> is the same number the weapon block uses. <code>recycleInterval</code> is the cooldown in seconds and <code>recycleLeft</code> the seconds until the item is ready again (0 = usable now; count it down client-side or re-request). <code>wear</code> true means it must be worn to use; <code>selfOnly</code> true means it always targets you. The spell level is not sent.
 +
* <code>flavor{}</code> — only on a drink container or fountain a druid has flavored: <code>{ &quot;spell&quot;, &quot;spellName&quot;, &quot;spellLevel&quot;, &quot;chance&quot;, &quot;leftSeconds&quot; }</code>. <code>spell</code> and <code>spellName</code> as in <code>use{}</code>; <code>chance</code> the percent chance a drink casts it; <code>leftSeconds</code> the real seconds until the flavor wears off, accurate to one tick, count it down like <code>recycleLeft</code>. The block disappears when the flavor expires, the container is emptied, or the character rents; each of those reaches a carried item through <code>char.items.update</code>, so drop it when the pushed item has no <code>flavor</code>. Fountains also still carry the raw <code>spell2*</code> slots in their per-type block; prefer <code>flavor{}</code>.
 +
* <code>props{}</code> — a '''whitelisted''' subset of the object’s key/value properties, present only when the server lists keys in its <code>GMCP_OBJECT_PROPS</code> setting and the object carries one of them. The default list is empty, so expect no <code>props{}</code> at all unless the server has opted keys in.
  
 
==== <code>char.inventory</code> — request, two modes ====
 
==== <code>char.inventory</code> — request, two modes ====
Line 416: Line 1,122:
  
 
<code>known</code> is the trained tier count, '''not''' a percentage.
 
<code>known</code> is the trained tier count, '''not''' a percentage.
 +
 +
==== <code>char.skills.query</code> — how you can qualify for a skill ====
 +
 +
Request either form:
 +
 +
<pre>char.skills.query 63
 +
char.skills.query { &quot;slot&quot;: 63 }</pre>
 +
<code>slot</code> is the skill number from <code>skills.all</code> or <code>char.skills</code>. One
 +
reply per request:
 +
 +
<pre>{ "slot": 63, "name": "kick", "status": "ok",
 +
  "paths": [
 +
    { "level": 5, "trainLevels": [12, 25],
 +
      "stats": { "str": 40, "min": 1, "dex": 45, "con": 30,
 +
                "per": 1, "spi": 1 },
 +
      "skills": [ { "slot": 108, "name": "martial arts" } ] }
 +
  ],
 +
  "exclusions": [] }</pre>
 +
Top-level fields, always present:
 +
 +
* <code>slot</code> — echoes the request; <code>-1</code> if the request itself was malformed (<code>&quot;bad_request&quot;</code>, below).
 +
* <code>name</code> — the skill name; <code>null</code> when <code>status</code> is <code>&quot;unknown_skill&quot;</code> or <code>&quot;bad_request&quot;</code>.
 +
* <code>status</code> — one of:
 +
** <code>&quot;ok&quot;</code> — at least one path follows.
 +
** <code>&quot;unknown_skill&quot;</code> — no skill exists at that slot.
 +
** <code>&quot;unavailable&quot;</code> — the skill exists, but no path is currently open to you.
 +
** <code>&quot;bad_request&quot;</code> — the request wasn’t a bare int or an object with an int <code>slot</code>.
 +
* <code>paths</code> — array of qualification paths; empty unless <code>status</code> is <code>&quot;ok&quot;</code>.
 +
* <code>exclusions</code> — array of <code>{slot, name}</code> for skills mutually exclusive with this one. Always present. Currently always empty on this server; the shape is reserved for when that gate is turned on.
 +
 +
You only ever receive paths available to your own character — the
 +
same set <code>QUERY FULL &lt;skill&gt;</code> would show you in the text game.
 +
 +
Per-path fields:
 +
 +
* <code>level</code> — minimum character level for this path.
 +
* <code>trainLevels</code> — levels where additional training tiers unlock '''for this path'''. Don’t confuse this with <code>skills.all</code>’s <code>trainLevels</code>: that one is a single character-independent schedule for the whole skill, while this one folds each path’s own minimum level into the schedule, so the same skill can report different <code>trainLevels</code> on different paths. Empty when the skill has only one tier.
 +
* <code>stats</code> — always all six, in a fixed order: <code>str</code>, <code>min</code>, <code>dex</code>, <code>con</code>, <code>per</code>, <code>spi</code>. A value of <code>1</code> means no requirement for that stat (the text view hides these; GMCP always sends the full shape and leaves hiding them up to you).
 +
* <code>skills</code> — prerequisite skills for this path, as <code>{slot, name}</code>. Empty array when the path has none. Cross-reference <code>slot</code> against your <code>char.skills</code> state to show which prerequisites you already have.
 +
 +
Immortal characters additionally receive, per path:
 +
 +
* <code>axioms</code> — <code>tech</code>, <code>magic</code>, <code>civ</code>, <code>nature</code>, <code>warcraft</code>.
 +
* <code>prestige</code> — an integer.
 +
 +
'''Worked example: <code>&quot;ok&quot;</code> with a single, empty path''' (bandage, slot 42;
 +
captured on the wire from a live mortal character):
 +
 +
<pre>{"slot": 42, "name": "bandage", "status": "ok",
 +
"paths": [
 +
  {"level": 0, "trainLevels": [],
 +
    "stats": {"str": 1, "min": 1, "dex": 1, "con": 1, "per": 1, "spi": 1},
 +
    "skills": []}
 +
],
 +
"exclusions": []}</pre>
 +
This is the everything-is-1 shape you will see a lot: no level
 +
requirement, no stat requirement, no skill prerequisite, one tier. The
 +
skill still comes back with <code>status: &quot;ok&quot;</code> and one path; a path with
 +
nothing in it is still a valid path.
 +
 +
'''Worked example: <code>&quot;unavailable&quot;</code>''' (twogun, slot 150; captured from a
 +
live mortal character with no open path to it):
 +
 +
<pre>{"slot": 150, "name": "twogun", "status": "unavailable",
 +
"paths": [], "exclusions": []}</pre>
 +
Note that <code>name</code> is still populated. The skill exists; this character
 +
simply has no path open to it right now. Compare this to
 +
<code>unknown_skill</code>, where <code>name</code> is <code>null</code> because the slot does not
 +
correspond to a real skill at all.
 +
 +
'''Worked example: <code>&quot;ok&quot;</code>, multiple paths, immortal viewer''' (expert
 +
parry, slot 103; captured from a live immortal character — three
 +
separate paths for the same skill, each carrying the imm-only <code>axioms</code>
 +
and <code>prestige</code> fields a mortal viewer never sees):
 +
 +
<pre>{"slot": 103, "name": "expert parry", "status": "ok",
 +
"paths": [
 +
  {"level": 30, "trainLevels": [],
 +
    "stats": {"str": 1, "min": 25, "dex": 80, "con": 1, "per": 20, "spi": 1},
 +
    "skills": [{"slot": 101, "name": "parry"}, {"slot": 102, "name": "advanced parry"}],
 +
    "axioms": {"tech": 3, "magic": 0, "civ": 4, "nature": 0, "warcraft": 0},
 +
    "prestige": 0},
 +
  {"level": 30, "trainLevels": [],
 +
    "stats": {"str": 1, "min": 25, "dex": 80, "con": 1, "per": 20, "spi": 1},
 +
    "skills": [{"slot": 101, "name": "parry"}, {"slot": 102, "name": "advanced parry"}, {"slot": 135, "name": "evasion"}],
 +
    "axioms": {"tech": 0, "magic": 0, "civ": 0, "nature": 0, "warcraft": 0},
 +
    "prestige": 0},
 +
  {"level": 30, "trainLevels": [],
 +
    "stats": {"str": 1, "min": 25, "dex": 80, "con": 1, "per": 20, "spi": 1},
 +
    "skills": [{"slot": 101, "name": "parry"}, {"slot": 102, "name": "advanced parry"}],
 +
    "axioms": {"tech": 4, "magic": 0, "civ": 3, "nature": 0, "warcraft": 0},
 +
    "prestige": 0}
 +
],
 +
"exclusions": []}</pre>
 +
Worth noticing: the <code>stats</code> and <code>level</code> are identical across all three
 +
paths (this skill’s variation between paths is entirely in the imm-only
 +
fields and the prerequisite <code>skills</code> list); and the order these three
 +
arrived in carries no meaning — path order is not stable, so sort
 +
client-side if you want a fixed display order.
  
 
=== 4.5 Magic: spells and words ===
 
=== 4.5 Magic: spells and words ===
Line 594: Line 1,399:
 
useful for graying out members you can’t currently assist.
 
useful for graying out members you can’t currently assist.
  
−
=== 4.12 Help ===
+
=== 4.12 Comm delivery and messages ===
 +
 
 +
Negotiated delivery of person-to-person, group/party, and public
 +
channel comm traffic. This is its own section, not part of Groups —
 +
<code>&quot;tell&quot;</code> (below) has nothing to do with grouping, it just shares the
 +
same negotiation and frame machinery as <code>&quot;gtell&quot;</code>/<code>&quot;ptell&quot;</code>. <code>&quot;channel&quot;</code>
 +
(below) covers the normal public channels (chat, muse, info, auction,
 +
death/level announcements, and so on) and has a different frame shape
 +
from the other three — see its own subsection.
 +
 
 +
==== <code>comm.delivery.set</code> — client → server, no reply ====
 +
 
 +
Tell the server how you want each comm kind delivered. Send any time
 +
after GMCP negotiates; takes effect immediately.
 +
 
 +
<pre>comm.delivery.set {&quot;gtell&quot;: &quot;gmcp&quot;, &quot;ptell&quot;: &quot;both&quot;, &quot;tell&quot;: &quot;both&quot;}</pre>
 +
The payload is an object mapping kind name to mode string.
 +
 
 +
* Kinds shipped so far: <code>&quot;gtell&quot;</code> (group tell), <code>&quot;ptell&quot;</code> (party tell), <code>&quot;tell&quot;</code> (person-to-person tell — covers the TELL, PAGE, REPLY, RETELL, and IMMREPLY commands, plus the board operator’s automatic tell; the frame is identical regardless of which command produced it, so don’t try to infer the command from the frame), and <code>&quot;channel&quot;</code> (every normal public channel — chat, muse, info, auction, death/level announcements, and so on, '''plus clan traffic'''. '''One key governs all of them''' — there is no per-channel negotiation; filter or mute a specific channel (or clan) client-side off the frame’s <code>subType</code>/<code>clan</code>, see below. Clan speech and clan socials ride this same key; there is no separate clan negotiation).
 +
* Modes: <code>&quot;text&quot;</code> (today’s behavior, no frame — the default for every kind), <code>&quot;both&quot;</code> (the text line still arrives, plus one <code>comm.message</code> frame in the same flush), <code>&quot;gmcp&quot;</code> (the frame arrives and the text line does not — use this only when your UI fully owns rendering that kind).
 +
* Kinds you omit keep their current mode. Unknown kind names and unrecognized mode strings are silently ignored (the server logs them as a client bug); your other kinds’ modes are left untouched.
 +
* '''Per connection, not persisted.''' A fresh descriptor starts every kind at <code>&quot;text&quot;</code>. Re-send your preferences on every connect and reconnect; nothing survives a disconnect server-side.
 +
* No ack. There is no reply to correlate against; the setting is in effect by the time your next message could observe it.
 +
 
 +
==== <code>comm.message</code> — pushed only, per your negotiated mode ====
 +
 
 +
One frame per receiving character whose mode for the kind is <code>&quot;gmcp&quot;</code>
 +
or <code>&quot;both&quot;</code> — and only when the text line would also have been sent
 +
(every game-side gate: group/party membership, silent rooms, tell
 +
refusals, channel subscription/ignores/gates, etc. is already applied
 +
before a frame is considered). Covers <code>&quot;gtell&quot;</code>, <code>&quot;ptell&quot;</code>, <code>&quot;tell&quot;</code>,
 +
and <code>&quot;channel&quot;</code> today; more kinds may come later. '''<code>&quot;tell&quot;</code> has one
 +
deliberate exception to the “no text, no frame” rule''' — see the AFK
 +
note below. <code>&quot;channel&quot;</code> frames have a different shape from the other
 +
three (no <code>color</code>, plus <code>subType</code>/<code>act</code>/<code>extraInfo</code>) — the table below
 +
covers <code>&quot;gtell&quot;</code>/<code>&quot;ptell&quot;</code>/<code>&quot;tell&quot;</code>; <code>&quot;channel&quot;</code>’s own field table and
 +
examples follow in its own subsection.
 +
 
 +
<pre>{ "kind": "gtell", "from": "Keldor", "text": "Test one.",
 +
  "line": "|U24Keldor tells the group, 'Test one.|U24'|U6",
 +
  "color": { "fg": 11, "bg": -1, "attrs": [] } }</pre>
 +
{| class="wikitable"
 +
|-
 +
! field
 +
! presence
 +
! notes
 +
|-
 +
| <code>kind</code>
 +
| always
 +
| <code>&quot;gtell&quot;</code>, <code>&quot;ptell&quot;</code>, or <code>&quot;tell&quot;</code>.
 +
|-
 +
| <code>from</code>
 +
| received frames
 +
| The speaker’s name as rendered ''for you'' — same visibility/disguise resolution as the text line, capitalized. An invisible speaker you can’t see through renders per kind, matching each kind’s own text-line convention exactly: for <code>&quot;gtell&quot;</code>/<code>&quot;ptell&quot;</code> it is parenthesized '''and capitalized''', e.g. <code>&quot;(Someone)&quot;</code> for an unseen immortal or <code>&quot;(Somebody)&quot;</code> for an unseen mortal (<code>channel_name()</code>/<code>channel_name_int()</code> hand-capitalizes inside the parens). For <code>&quot;tell&quot;</code> there are no parens at all — the bare capitalized form, <code>&quot;Someone&quot;</code>/<code>&quot;Somebody&quot;</code> (<code>capitalize_first(PERS(...))</code>). '''<code>&quot;channel&quot;</code> frames render this differently again — lowercase, unlike either of the above — see the channel subsection below''', this row does not describe it. Never a name you couldn’t already see in text. '''Omitted on your own outgoing echo''' — that is the reliable self-marker; don’t parse <code>line</code> for “You tell”.
 +
|-
 +
| <code>to</code>
 +
| <code>&quot;tell&quot;</code> echo frames only
 +
| The tell target’s name, rendered for you the same way <code>from</code> is rendered for a receiver. This is how you know which conversation a sent tell belongs to. Mutually exclusive with <code>from</code> — a frame never carries both; <code>to</code> never appears on <code>&quot;gtell&quot;</code>/<code>&quot;ptell&quot;</code> frames or on received <code>&quot;tell&quot;</code> frames.
 +
|-
 +
| <code>text</code>
 +
| always
 +
| The message body, no server-added color codes, no surrounding quotes. For <code>&quot;gtell&quot;</code>/<code>&quot;ptell&quot;</code> this is ''after'' the server’s capitalize/punctuate pass. '''For <code>&quot;tell&quot;</code> it is not''' — the body arrives exactly as typed, no capitalization or trailing period added; render it verbatim.
 +
|-
 +
| <code>line</code>
 +
| always
 +
| The exact text line, u-color codes included, trailing CRLF stripped. In <code>&quot;gmcp&quot;</code> mode this is the line you would otherwise have received as text — render or discard it as you like.
 +
|-
 +
| <code>color</code>
 +
| always
 +
| Your own configured color for this kind, the same <code>{fg, bg, attrs}</code> shape as <code>char.colors</code> (§4.1): <code>fg</code>/<code>bg</code> are the server’s palette ints, <code>attrs</code> an array of SGR attribute names. <code>gtell</code> resolves your u-color slot 24, <code>ptell</code> slot 39, <code>tell</code> slot 14 — the same slots the text line’s <code>\|U24</code>/<code>\|U39</code>/<code>\|U14</code> codes select.
 +
|}
 +
 
 +
Two group members with different color settings get different <code>color</code>
 +
values for the same gtell — it reflects ''your'' config, not the
 +
speaker’s; use it to tint your Chat/UI rendering to match what the
 +
player configured in-game.
 +
 
 +
'''Worked examples''' (gtell/ptell captured live on <code>features/gmcp_comm</code>;
 +
tell captured live on <code>features/gmcp_tell</code>):
 +
 
 +
Third-party gtell, <code>&quot;both&quot;</code> mode:
 +
 
 +
<pre>{ "kind": "gtell", "from": "Keldor", "text": "Test one.",
 +
  "line": "|U24Keldor tells the group, 'Test one.|U24'|U6",
 +
  "color": { "fg": 11, "bg": -1, "attrs": [] } }</pre>
 +
Your own echo (no <code>from</code>):
 +
 
 +
<pre>{ "kind": "gtell", "text": "My own echo.",
 +
  "line": "|U24You tell the group, 'My own echo.|U24'|U6",
 +
  "color": { "fg": 11, "bg": -1, "attrs": [] } }</pre>
 +
Party tell, color resolved from <code>ptell</code>’s own slot (distinct from the
 +
gtell example above even for the same speaker/session):
 +
 
 +
<pre>{ "kind": "ptell", "from": "Keldor", "text": "Check.",
 +
  "line": "|U39Keldor tells the party, 'Check.|U39'|U6",
 +
  "color": { "fg": 3, "bg": -1, "attrs": [] } }</pre>
 +
Received tell, raw lowercase/no-period body (<code>text</code> byte-identical to
 +
what was typed — no server capitalization, no trailing period added):
 +
 
 +
<pre>{ "kind": "tell", "from": "Keldor", "text": "step two lowercase body no period",
 +
  "line": "|U14Keldor tells you, 'step two lowercase body no period|U14'|U6",
 +
  "color": { "fg": 2, "bg": -1, "attrs": [] } }</pre>
 +
Your own sent tell (echo — <code>to</code>, no <code>from</code>):
 +
 
 +
<pre>{ "kind": "tell", "to": "Keldor", "text": "step three echo check",
 +
  "line": "|U14You tell Keldor, 'step three echo check|U14'|U6",
 +
  "color": { "fg": 2, "bg": -1, "attrs": [] } }</pre>
 +
AFK-hidden tell, frame arrives while the text line is suppressed (see
 +
the AFK note below):
 +
 
 +
<pre>{ "kind": "tell", "from": "Keldor", "text": "step five afk hidden both mode",
 +
  "line": "|U14Keldor tells you, 'step five afk hidden both mode|U14'|U6",
 +
  "color": { "fg": 2, "bg": -1, "attrs": [] } }</pre>
 +
Integration notes:
 +
 
 +
* '''Mode also governs your own echo.''' In <code>&quot;gmcp&quot;</code> mode your own sent gtell/ptell/tell produces no text line either — only the frame.
 +
* '''Frame and text share a flush in <code>&quot;both&quot;</code> mode'''; order between them is not guaranteed. If you need to correlate, match the color-stripped <code>line</code> against the adjacent scrollback line.
 +
* '''Keep your existing text classifier as fallback''' for kinds without frames yet (channels, says, and so on) — negotiation is per kind precisely so you can migrate one at a time.
 +
* '''AFK capture is unaffected by mode (gtell/ptell).''' A player in <code>&quot;gmcp&quot;</code> mode with <code>GTELL TO AFK</code> configured still accumulates gtells in their AFK log server-side, even though no text line reaches the descriptor live.
 +
* '''Tell’s AFK-hide exception.''' A player who is AFK with the hide-tells-while-AFK config on gets no text line for an incoming tell today, in any mode — that config hides scrollback clutter, it does not mean “not received.” In <code>&quot;gmcp&quot;</code> or <code>&quot;both&quot;</code> mode you may get a <code>comm.message</code> frame for a tell whose text the player chose to hide while AFK — render it; that is the point. This is the one place a <code>&quot;tell&quot;</code> frame arrives with no matching text line even in <code>&quot;both&quot;</code> mode; every other frame in this section still follows “text line sent ⇒ frame eligible.”
 +
* Don’t re-render <code>text</code> with your own sentence-casing. For <code>&quot;gtell&quot;</code>/<code>&quot;ptell&quot;</code> the server already capitalized and punctuated it, so it’s display-ready as-is. For <code>&quot;tell&quot;</code>, there is nothing to strip or add — the body is raw on the wire by design; apply your own formatting if your UI wants any.
 +
 
 +
Full contract and rationale:
 +
<code>docs/updates/2026-08-05-gmcp-comm-message-handover.md</code> (gtell/ptell)
 +
and <code>docs/updates/2026-08-05-gmcp-comm-tell-handover.md</code> (tell).
 +
 
 +
==== <code>comm.message</code> — channel frames (<code>kind: &quot;channel&quot;</code>) ====
 +
 
 +
One <code>&quot;channel&quot;</code> key in <code>comm.delivery.set</code> governs every normal public
 +
channel (chat, muse, info, auction, death/level announcements, and
 +
whatever else flows through the game’s channel system) — there is no
 +
per-channel negotiation. Filter or mute a specific channel client-side
 +
off the frame’s <code>subType</code>.
 +
 
 +
'''Clan traffic rides this same <code>&quot;channel&quot;</code> kind.''' Clan speech and
 +
clan socials both arrive as <code>kind: &quot;channel&quot;</code> with <code>subType: &quot;Clan&quot;</code>
 +
(the constant string, not a per-clan name) plus a <code>clan</code> member
 +
carrying the capitalized clan keyword exactly as the text tag shows it
 +
(e.g. <code>&quot;Gmcpone&quot;</code>) — or <code>&quot;All&quot;</code> on an immortal’s copy of an all-clans
 +
broadcast. <code>from</code>, <code>self</code>, and <code>act</code> behave exactly as they do on
 +
every other channel: <code>from</code> is present on spoken messages and absent
 +
on code-generated clan announcements, <code>self: true</code> marks your own
 +
copy, <code>act: true</code> marks a clan social. There is no separate
 +
negotiation key for clan — the same <code>&quot;channel&quot;</code> mode you set governs
 +
it. Clan channels still never appear in the <code>comm.channels</code> catalog
 +
(below) — key your clan UI off the <code>clan</code> member on each frame, not
 +
off a catalog entry that will never exist.
 +
 
 +
Player-sent chat, as received by a subscriber in <code>&quot;both&quot;</code>/<code>&quot;gmcp&quot;</code>
 +
mode:
 +
 
 +
<pre>{ "kind": "channel", "subType": "Chat", "from": "Bob",
 +
  "text": "anyone around?",
 +
  "line": "<the line exactly as YOUR chan_format rendered it>" }</pre>
 +
Code-generated announcement (info/auction/death/level — no speaker):
 +
 
 +
<pre>{ "kind": "channel", "subType": "Info",
 +
  "text": "Welcome to the world, Rusalka!",
 +
  "line": "..." }</pre>
 +
{| class="wikitable"
 +
|-
 +
! field
 +
! presence
 +
! notes
 +
|-
 +
| <code>kind</code>
 +
| always
 +
| <code>&quot;channel&quot;</code>.
 +
|-
 +
| <code>subType</code>
 +
| always
 +
| The channel’s name, verbatim as configured (<code>&quot;Chat&quot;</code>, <code>&quot;Muse&quot;</code>, <code>&quot;Info&quot;</code>, <code>&quot;Auction&quot;</code>, …) — channel names arrive capitalized, not lowercase; treat as an opaque identifier-plus-display-string, not something to parse further. Clan traffic uses the constant <code>&quot;Clan&quot;</code> regardless of which clan — split clan frames by the <code>clan</code> member below, not by <code>subType</code>.
 +
|-
 +
| <code>clan</code>
 +
| Clan traffic only, else omitted
 +
| The capitalized clan keyword exactly as the text tag shows it (e.g. <code>&quot;Gmcpone&quot;</code>), or <code>&quot;All&quot;</code> on an immortal’s copy of a broadcast sent to every clan at once (no single clan to name). Absent on every non-clan channel frame. This is the field to split clan tabs on — <code>subType</code> is always <code>&quot;Clan&quot;</code> and carries no per-clan information by itself.
 +
|-
 +
| <code>from</code>
 +
| speaker frames only
 +
| Present when a character spoke; rendered ''for you'' — per-viewer by the same identity pipeline as the text line. Unseen speakers render parenthesized with lowercase inside the parens, e.g. <code>&quot;(someone)&quot;</code> for an unseen immortal or <code>&quot;(somebody)&quot;</code> for an unseen mortal — this does NOT match gtell/ptell’s capitalized <code>&quot;(Someone)&quot;</code>/<code>&quot;(Somebody)&quot;</code>, nor tell’s bare <code>&quot;Someone&quot;</code>/<code>&quot;Somebody&quot;</code> (see the <code>from</code> row in the shared table above); channel is its own third rendering path. Capitalization applies normally except where blocked by a leading paren, leaving letters immediately after an opening paren lowercase — that’s why unseen speakers show <code>(someone)</code>. Matches the text line’s own rendering exactly (same bug, not a frame-only artifact); documented as-is, not fixed here. Absent on speakerless traffic (info, auction, death, level). '''This is how you tell an announcement from a speech message — check whether <code>from</code> is present, don’t infer it from <code>subType</code>.''' '''Not omitted on your own outgoing echo''' — unlike gtell/ptell/tell, channels have no “You” self-shape in the line, so your own sent chat still carries your own rendered <code>from</code>. '''Do NOT compare <code>from</code> to your own character name to detect your own message''' — a disguised speaker’s <code>from</code> is the disguised name on their own copy too, so a name comparison misfires exactly when it matters most. Use the <code>self</code> member below instead.
 +
|-
 +
| <code>self</code>
 +
| <code>true</code> on your own copy, else omitted
 +
| Present (and <code>true</code>) only on the frame delivered to the speaker’s own connection; every other viewer’s copy of the identical message omits the member entirely (never <code>false</code>). This is the only reliable self-detection signal for channel frames: <code>from</code> carries the same rendered name (disguised or not) on every copy including your own, so it cannot distinguish “I said this” from “someone who looks like me said this.” Absent on speakerless traffic (info, auction, death, level) — there is no speaker to be.
 +
|-
 +
| <code>text</code>
 +
| always
 +
| The message body: the raw text for a player send, the whole rendered line for an emote/social (see <code>act</code> below) — no server-added color codes, no per-viewer channel bracket, no chan_format decoration.
 +
|-
 +
| <code>line</code>
 +
| always
 +
| The full line exactly as rendered through '''your own''' <code>chan_format</code> — including a custom one you configured with CHANFORMAT. u-color codes included, trailing CRLF stripped. This is the case a client-side regex could never reliably parse; the frame is authoritative. Two subscribers with different <code>chan_format</code>s get different <code>line</code> values for the identical message — render per frame, don’t dedupe or cache by <code>line</code>.
 +
|-
 +
| <code>act</code>
 +
| <code>true</code>, else omitted
 +
| Present (and <code>true</code>) only when the message is emote/social-form (e.g. <code>chat smile</code>) — <code>text</code>/<code>line</code> carry the whole rendered act, with no <code>Name:</code> speaker-prefix shape. Omitted (not <code>false</code>) for ordinary speech.
 +
|-
 +
| <code>extraInfo</code>
 +
| conditional, else omitted
 +
| <code>&quot;channel_timeout&quot;</code> on an immortal’s copy of a message from a sender who is in channel timeout — mortal viewers of that sender get nothing at all (no frame, no text), and the sender’s own copy never carries it either. Omitted whenever there is nothing to say. '''Ignore any value you don’t recognize''' — this member is reserved for future markers and the set may grow without a client-version bump.
 +
|-
 +
| <code>color</code>
 +
| never present
 +
| Channels take their color from '''your own''' <code>chan_format</code>, not a fixed server u-slot — the codes already embedded in <code>line</code> are the styling. Don’t wait for a <code>color</code> member; parse <code>line</code>’s codes or style the message yourself.
 +
|-
 +
| <code>to</code>
 +
| never present
 +
| Channels are broadcast, not directed — there is no per-recipient target to name.
 +
|}
 +
 
 +
'''Worked examples''' (all captured live on <code>features/gmcp_channel</code>,
 +
<code>.superpowers/sdd/gmcpchan-task-3-report.md</code> — ground truth for the
 +
exact wire shapes below):
 +
 
 +
Third-party chat, receiver in <code>&quot;both&quot;</code> mode:
 +
 
 +
<pre>{ "kind": "channel", "subType": "Chat", "from": "Keldor",
 +
  "text": "step2 both mode marker bravo",
 +
  "line": "|U7[|U8Chat|U7]|U6 |U9Keldor|U7:|U6 |U10step2 both mode marker bravo|U6" }</pre>
 +
Sender’s own echo — <code>from</code> is still present (contrast gtell/ptell/tell,
 +
where the sender’s own frame omits <code>from</code> entirely) and <code>self: true</code>
 +
marks it as your own copy; every other viewer’s frame for the same
 +
message has no <code>self</code> member at all:
 +
 
 +
<pre>{ "kind": "channel", "subType": "Chat", "from": "Keldor",
 +
  "text": "step3 own echo marker charlie",
 +
  "line": "|U7[|U8Chat|U7]|U6 |U9Keldor|U7:|U6 |U10step3 own echo marker charlie|U6",
 +
  "self": true }</pre>
 +
Disguised sender’s own echo — <code>from</code> is the DISGUISED name, not the
 +
real one, and <code>self: true</code> is still present. This is exactly the case
 +
<code>self</code> exists for: a disguised speaker’s <code>from</code> renders identically
 +
for every looker including themselves (<code>name()</code>/<code>PERS()</code> have no
 +
self-exception), so comparing <code>from</code> to your own character name would
 +
misclassify your own disguised message as someone else’s:
 +
 
 +
<pre>{ "kind": "channel", "subType": "Chat", "from": "An overworked milkmaid",
 +
  "text": "self flag probe disguised",
 +
  "line": "|U7[|U8Chat|U7]|U6 |U9An overworked milkmaid|U7:|U6 |U10self flag probe disguised|U6",
 +
  "self": true }</pre>
 +
The other viewer’s copy of the same disguised message carries the
 +
identical <code>from</code> value and no <code>self</code> member.
 +
 
 +
INFO announcement — no <code>from</code> member at all (not an empty string; the
 +
member is absent):
 +
 
 +
<pre>{ "kind": "channel", "subType": "Info",
 +
  "text": "Please congratulate Keldor, the newest Hero of Legend!",
 +
  "line": "|U7[|U8Info|U7]|U6 |U10Please congratulate Keldor, the newest Hero of Legend!|U6" }</pre>
 +
Channel social/emote — <code>act: true</code>, <code>text</code> carries the whole rendered
 +
act, <code>line</code> has no <code>Name:</code> speaker-prefix shape:
 +
 
 +
<pre>{ "kind": "channel", "subType": "Chat", "from": "Keldor",
 +
  "text": "Keldor smiles happily.",
 +
  "line": "|U7[|U8Chat|U7]|U6 |U10Keldor smiles happily.|U6",
 +
  "act": true }</pre>
 +
Channel timeout, immortal viewer’s frame — <code>extraInfo</code> present, <code>line</code>
 +
carries no decoration (the immortal’s ''text'' line gets a
 +
<code>(channel_timeout)</code> prefix that the frame’s <code>line</code> never repeats):
 +
 
 +
<pre>{ "kind": "channel", "subType": "Chat", "from": "Keldor",
 +
  "text": "step8 channel timeout marker",
 +
  "line": "|U7[|U8Chat|U7]|U6 |U9Keldor|U7:|U6 |U10step8 channel timeout marker|U6",
 +
  "extraInfo": "channel_timeout" }</pre>
 +
A mortal viewer of the same sender gets no frame and no text at all
 +
for that message; the sender’s own copy carries no <code>extraInfo</code>.
 +
 
 +
Invisible immortal speaker — <code>from</code> is the parenthesized, lowercase
 +
someone-form; the real name never touches the wire:
 +
 
 +
<pre>{ "kind": "channel", "subType": "Chat", "from": "(someone)",
 +
  "text": "step9 invis imm marker",
 +
  "line": "|U7[|U8Chat|U7]|U6 |U9(someone)|U7:|U6 |U10step9 invis imm marker|U6" }</pre>
 +
An unseen mortal speaker renders <code>&quot;(somebody)&quot;</code> in the identical
 +
position (same code path, <code>is_mortal()</code> branch — not independently
 +
wire-captured, but the flags and call site are identical to the
 +
immortal case above).
 +
 
 +
'''Clan traffic''' (captured live, <code>.superpowers/sdd/gmcpclan-task-3-report.md</code>
 +
— ground truth for the exact wire shapes below):
 +
 
 +
Plain clan speech, a same-clan third-party listener in <code>&quot;both&quot;</code> mode:
 +
 
 +
<pre>{ "kind": "channel", "subType": "Clan", "clan": "Gmcpone", "from": "Mandolin",
 +
  "text": "p1 plain speech probe",
 +
  "line": "|U7[|U8|U25Clan: Gmcpone|U7|U7]|U6 |U9Mandolin|U7:|U6 |U10p1 plain speech probe|U6" }</pre>
 +
The speaker’s own copy of the same message — <code>self: true</code>, <code>from</code>
 +
still present (clan frames never omit <code>from</code> on your own echo, same
 +
as every other channel frame):
 +
 
 +
<pre>{ "kind": "channel", "subType": "Clan", "clan": "Gmcpone", "from": "Mandolin",
 +
  "text": "p1 plain speech probe",
 +
  "line": "|U7[|U8|U25Clan: Gmcpone|U7|U7]|U6 |U9Mandolin|U7:|U6 |U10p1 plain speech probe|U6",
 +
  "self": true }</pre>
 +
Clan social (<code>clan smile</code>) — dispatches as the social, <code>act: true</code>,
 +
<code>text</code>/<code>line</code> carry the whole rendered act with no <code>Name:</code> prefix
 +
shape, exactly like a public-channel social:
 +
 
 +
<pre>{ "kind": "channel", "subType": "Clan", "clan": "Gmcpone", "from": "Mandolin",
 +
  "text": "Mandolin smiles happily.",
 +
  "line": "|U7[|U8|U25Clan: Gmcpone|U7|U7]|U6 |U10Mandolin smiles happily.|U6",
 +
  "act": true, "self": true }</pre>
 +
All-clans broadcast, an immortal’s own copy — <code>clan: &quot;All&quot;</code> where a
 +
mortal viewer of the identical broadcast would see their own clan’s
 +
keyword instead (each mortal is keyed to their own clan, never <code>&quot;All&quot;</code>):
 +
 
 +
<pre>{ "kind": "channel", "subType": "Clan", "clan": "All", "from": "Rufus",
 +
  "text": "p3 all clans broadcast probe",
 +
  "line": "|U7[|U8|U25Clan: All|U7|U7]|U6 |U9Rufus|U7:|U6 |U10p3 all clans broadcast probe|U6",
 +
  "self": true }</pre>
 +
Code-generated clan announcement (no speaker) — no <code>from</code>, no <code>self</code>,
 +
same rule as an INFO/AUCTION announcement:
 +
 
 +
<pre>{ "kind": "channel", "subType": "Clan", "clan": "Gmcpone",
 +
  "text": "The Gmcptwo Betas is now a friend of The Gmcpone Alphas.",
 +
  "line": "|U7[|U8|U25Clan: Gmcpone|U7|U7]|U6 |U10The Gmcptwo Betas is now a friend of The Gmcpone Alphas.|U6" }</pre>
 +
Integration notes:
 +
 
 +
* '''Subscription still rules.''' Channel on/off, ignores, silent rooms, sleep, PK gates — a message you wouldn’t have received as text never frames either. Negotiation only changes the transport, not what you receive.
 +
* '''<code>from</code>’s presence, not <code>subType</code>, distinguishes an announcement from a spoken message.''' <code>subType</code> names the channel either way; only speech has a speaker.
 +
* '''No per-channel delivery keys.''' One <code>&quot;channel&quot;</code> negotiation governs chat, muse, info, auction, and every other channel; do your own per-channel muting client-side against <code>subType</code>.
 +
* '''Sent == framed, per viewer.''' Because <code>line</code> is rendered through each viewer’s own <code>chan_format</code>, the same underlying message produces different <code>line</code> bytes for different subscribers — never key a cache off <code>line</code> alone.
 +
* '''Detect your own message via <code>self</code>, never via <code>from</code>.''' <code>from</code> is the rendered, possibly disguised name on every copy, your own included — a disguised speaker’s own echo carries the disguised name in <code>from</code> too. <code>self: true</code> is the only member that is present on your own copy and absent on everyone else’s.
 +
* '''Clan is a <code>subType</code>, not a new negotiation.''' Clan frames arrive under the same <code>&quot;channel&quot;</code> mode you already negotiated — there is no <code>&quot;clan&quot;</code> key in <code>comm.delivery.set</code>. Recognize clan frames by <code>subType === &quot;Clan&quot;</code> and split them by the <code>clan</code> member; everything else (<code>from</code>, <code>self</code>, <code>act</code>, <code>line</code> rendering through the viewer’s own channel format) works exactly like a public channel.
 +
* '''Clan channels are never in the <code>comm.channels</code> catalog''', even though clan traffic itself does flow through <code>comm.message</code> — see the catalog section below. Don’t gate clan-tab UI on a catalog entry that will never arrive.
 +
 
 +
Full contract and rationale:
 +
<code>docs/updates/2026-08-05-gmcp-comm-channel-handover.md</code>,
 +
<code>docs/updates/2026-08-06-gmcp-ucode-slot-map-handover.md</code> (<code>self</code> and
 +
the <code>slots</code> group), and
 +
<code>docs/updates/2026-08-06-gmcp-clan-channel-handover.md</code> (clan
 +
unification and the <code>clan</code> member).
 +
 
 +
==== <code>comm.channels</code> — request, login push, and change push ====
 +
 
 +
The catalog of public channel names — bare names only, no per-viewer
 +
state (no subscribed flag, no ownership, no welcome text, no flags).
 +
Request with an empty body; also pushed once at login/reconnect
 +
(alongside <code>char.colors</code>) and again, in full, any time the channel
 +
table changes.
 +
 
 +
<pre>{ "channels": ["Chat", "Info", "Auction", "Warzone", "Muse", "Event"] }</pre>
 +
* '''Names join <code>comm.message</code>’s <code>subType</code> byte-identically.''' Every entry is exactly the same capitalized string a <code>&quot;channel&quot;</code>-kind <code>comm.message</code> frame carries in <code>subType</code> (above) — key your channel-list UI on these names directly, no normalization needed.
 +
* '''Table order, not alphabetized.''' Treat order as insignificant; don’t rely on it for display sorting.
 +
* '''Full snapshot every time — replace, don’t diff.''' Every push (login, on request, or on change) is the complete current list. Adopt it wholesale each time; there is no delta form and none is planned.
 +
* '''Three send moments''': login/character entry, on request (empty body, like the other snapshot packages), and on any table change (a channel created, deleted, renamed, or modified) — the change push goes to every GMCP-enabled connection, not just the one that triggered it.
 +
* '''Unknown-<code>subType</code> race window.''' The catalog and channel frames are two independent pushes, so a <code>comm.message</code> frame can name a channel you haven’t seen in a catalog snapshot yet (freshly created, catalog push still in flight) or one just removed (a frame sent just before a delete can arrive after the catalog already dropped it). Treat any <code>subType</code> as valid on arrival — render it even if it’s not currently in your catalog — and let the next catalog push reconcile your list. Don’t gate frame handling on catalog presence.
 +
* '''Clan channels are absent.''' The clan pseudo-channel never appears in this catalog and never will — it isn’t a row in the channel table ordinary channels come from. This is not the same as being out of scope: clan traffic itself does arrive over <code>comm.message</code> (<code>kind: &quot;channel&quot;</code>, <code>subType: &quot;Clan&quot;</code>, plus a <code>clan</code> member — see the clan examples above). Use that <code>clan</code> member, not a catalog lookup, to build clan-specific UI; treat <code>subType: &quot;Clan&quot;</code> as always valid on arrival even though <code>&quot;Clan&quot;</code> will never show up in a <code>comm.channels</code> snapshot.
 +
 
 +
Full contract: <code>docs/updates/2026-08-06-gmcp-channel-catalog-handover.md</code>.
 +
 
 +
=== 4.13 Help ===
  
 
==== <code>help.topic</code> — request with <code>{&quot;keywords&quot;: &quot;...&quot;}</code> ====
 
==== <code>help.topic</code> — request with <code>{&quot;keywords&quot;: &quot;...&quot;}</code> ====
Line 610: Line 1,765:
 
* The reply always has these four fields, so you can parse it with a fixed shape.
 
* The reply always has these four fields, so you can parse it with a fixed shape.
  
−
=== 4.13 Journal ===
+
=== 4.14 Journal ===
  
 
==== <code>char.journal</code> — request, also pushed ====
 
==== <code>char.journal</code> — request, also pushed ====
Line 648: Line 1,803:
 
* '''Unknown, unheld, and malformed requests all get the same reply''': <code>{ &quot;vnum&quot;: N, &quot;found&quot;: false }</code> — nothing else. That covers a vnum that doesn’t exist, a real quest vnum you don’t currently hold, and a request with a missing or non-numeric <code>vnum</code> (which echoes back as <code>0</code>). This is deliberate: the reply gives you no way to tell “no such quest” from “not your quest,” so <code>char.journal.entry</code> can’t be used to fish for quests in the game you haven’t found yet.
 
* '''Unknown, unheld, and malformed requests all get the same reply''': <code>{ &quot;vnum&quot;: N, &quot;found&quot;: false }</code> — nothing else. That covers a vnum that doesn’t exist, a real quest vnum you don’t currently hold, and a request with a missing or non-numeric <code>vnum</code> (which echoes back as <code>0</code>). This is deliberate: the reply gives you no way to tell “no such quest” from “not your quest,” so <code>char.journal.entry</code> can’t be used to fish for quests in the game you haven’t found yet.
 
* There’s no <code>journals.all</code> — LegendMUD doesn’t ship a static catalog of every quest in the game the way it does for skills or spells, since that would spoil quests you haven’t discovered. Fetch <code>char.journal</code> for what you hold, and <code>char.journal.entry</code> per vnum for detail; don’t wait for a bulk catalog package that isn’t coming.
 
* There’s no <code>journals.all</code> — LegendMUD doesn’t ship a static catalog of every quest in the game the way it does for skills or spells, since that would spoil quests you haven’t discovered. Fetch <code>char.journal</code> for what you hold, and <code>char.journal.entry</code> per vnum for detail; don’t wait for a bulk catalog package that isn’t coming.
 +
 +
=== 4.15 Map panel ===
 +
 +
==== <code>map.ansi.view</code> — request, and pushed while subscribed ====
 +
 +
The MAP VIEW sketch as '''terminal text''', sized for a panel of your
 +
own. This is not map data: it is the finished picture the game would
 +
print, escape sequences and all, for you to drop into an ANSI-aware
 +
widget. Build a real map from <code>room.info</code> (§4.2) instead; this is the
 +
game’s own drawing, for clients that would rather show that.
 +
 +
<pre>map.ansi.view {&quot;width&quot;: 60, &quot;height&quot;: 30}</pre>
 +
<pre>{ "found": true, "width": 60, "height": 30,
 +
  "text": "\u001b[0;36m[Naraka] \u001b[0;37mA Shrine to Yama\r\n …" }</pre>
 +
<pre>{ "found": false, "width": 80, "height": 24,
 +
  "reason": "You cannot see to draw anything." }</pre>
 +
* '''<code>text</code> is terminal-ready.''' Rows are joined with <code>\r\n</code> and there is no trailing one. It carries real ANSI escapes when the player has color on, and plain text when they don’t — the same output MAP VIEW sends to the screen, rendered at their own color setting. Render it in a fixed-width, ANSI-aware view; don’t parse it, and don’t strip the escapes and expect the sketch to still line up in color.
 +
* '''Size is optional and always clamped, never refused.''' Omit <code>width</code>/<code>height</code> (or send <code>0</code>) and you get the player’s own screen, with an 80x24 fallback. Whatever you ask for is held to 22-511 columns and 4-100 rows, and the reply '''echoes the size you actually got''' — match your panel to that, not to what you asked for. A very large canvas does not draw a bigger map: the sketch has its own ceiling and simply centers in what you gave it.
 +
* '''<code>found: false</code>''' means there is no map for this player right now, and <code>reason</code> is the one line the game itself would show (no cartography skill, blind, and so on). Blank the panel and show the reason. Standing somewhere the game can’t lay out — inside an instance, say — is '''not''' a refusal: you get <code>found: true</code> and a one-line <code>text</code> saying so.
 +
 +
==== <code>map.ansi.subscribe</code> — opt in, then it follows you ====
 +
 +
<pre>map.ansi.subscribe {&quot;enabled&quot;: true, &quot;width&quot;: 60, &quot;height&quot;: 30}</pre>
 +
<pre>{ "enabled": true, "width": 60, "height": 30 }</pre>
 +
* Send it '''once per connection''', after you know your panel size. A bare <code>map.ansi.subscribe {}</code> subscribes at your screen size; <code>{&quot;enabled&quot;: false}</code> stops it (and the reply’s size fields come back <code>0</code>).
 +
* Subscribing sends one <code>map.ansi.view</code> straight away, so the panel fills immediately instead of waiting for the player to move.
 +
* After that, one <code>map.ansi.view</code> arrives '''after every <code>room.info</code> push''' — that is, on every move, look, login and reconnect — at the size you subscribed with. Nothing else triggers it: the sketch only ever changes when the player moves.
 +
* '''Resizing your panel means subscribing again''' with the new size; the server remembers the size you gave it, not your terminal’s.
 +
* The subscription lives on the connection. A reconnect starts unsubscribed, so send it again as part of your session bootstrap.
 +
 +
=== 4.16 Media: sound and music (<code>Client.Media</code>) ===
 +
 +
The server speaks the
 +
[https://wiki.mudlet.org/w/Standards:MUD_Client_Media_Protocol MUD Client Media Protocol]
 +
(Mudlet, BeipMU and LociTerm implement it natively). It is '''off unless you
 +
ask''': list <code>&quot;Client.Media 1&quot;</code> in <code>core.supports.set</code> (or add it with
 +
<code>core.supports.add</code>) and the server starts sending; a later <code>set</code> without it,
 +
or a <code>remove</code>, stops it. The server operator can also switch the whole
 +
feature off, in which case you get nothing whatever you declare.
 +
 +
The subscription comes in '''two halves''' you can take separately, so a
 +
client can offer a music switch and a sound-effects switch:
 +
 +
{| class="wikitable"
 +
|-
 +
! Entry
 +
! You receive
 +
|-
 +
| <code>&quot;Client.Media 1&quot;</code>
 +
| both halves (what the spec’s clients send)
 +
|-
 +
| <code>&quot;Client.Media.Music 1&quot;</code>
 +
| background music from area files and the login-screen track (<code>&quot;type&quot;: &quot;music&quot;</code>)
 +
|-
 +
| <code>&quot;Client.Media.Sound 1&quot;</code>
 +
| ambient sounds from area files, doors, locks and script sounds (<code>&quot;type&quot;: &quot;sound&quot;</code>)
 +
|}
 +
 +
Flip a half mid-game with <code>core.supports.add</code> / <code>core.supports.remove</code> of
 +
that entry: dropping music sends a <code>client.media.stop</code> with <code>fadeaway</code> for
 +
the track that was playing, taking it back sends the play for wherever you
 +
are standing, and the other half is untouched. Every stop the server
 +
sends carries a <code>type</code>, so a stop only ever matches the half it belongs
 +
to. A <code>_media stop</code> from a script with no type or key stops everything and
 +
reaches a client holding either half.
 +
 +
All four packages are server→client. Values are JSON numbers and booleans
 +
(the spec allows strings too, so a tolerant parser is wise):
 +
 +
{| class="wikitable"
 +
|-
 +
! Package
 +
! Body
 +
|-
 +
| <code>client.media.default</code>
 +
| <code>{ &quot;url&quot;: &quot;https://…/media/&quot; }</code> — the base directory, once per connection before the first play/load. Always ends in <code>/</code>. Resolve every <code>name</code> against it unless a message carries its own <code>url</code>.
 +
|-
 +
| <code>client.media.load</code>
 +
| <code>{ &quot;name&quot;, &quot;url&quot;? }</code> — prefetch a file.
 +
|-
 +
| <code>client.media.play</code>
 +
| <code>{ &quot;name&quot;, &quot;url&quot;?, &quot;type&quot;?, &quot;tag&quot;?, &quot;source&quot;?, &quot;key&quot;?, &quot;caption&quot;?, &quot;volume&quot;?, &quot;loops&quot;?, &quot;fadein&quot;?, &quot;fadeout&quot;?, &quot;start&quot;?, &quot;finish&quot;?, &quot;priority&quot;?, &quot;continue&quot;? }</code> — only the members that were set arrive; spec defaults apply to the rest (<code>type</code> sound, <code>volume</code> 50, <code>loops</code> 1, <code>continue</code> true). <code>source</code> is this server’s addition to the spec, see below.
 +
|-
 +
| <code>client.media.stop</code>
 +
| <code>{ &quot;name&quot;?, &quot;type&quot;?, &quot;tag&quot;?, &quot;key&quot;?, &quot;priority&quot;?, &quot;fadeaway&quot;?, &quot;fadeout&quot;? }</code> — stop what matches; <code>{}</code> stops everything.
 +
|}
 +
 +
Semantics you must honor for the game to sound right:
 +
 +
* '''<code>key</code>''': a new play with the same key but a different <code>name</code> halts the old one. Crossfade the handover: the old play fades out over its <code>fadeout</code> while the new one fades in over its <code>fadein</code>, and treat an absent value as 2000 ms (the server omits zero-valued members, so a builder who wants a hard cut sends a small value such as 50). Area music always uses <code>&quot;key&quot;: &quot;area-music&quot;</code>; ambient sounds from area files carry their own keys and layer over it.
 +
* '''<code>continue</code>''': with <code>true</code>, a play naming the track already playing under that key keeps it going instead of restarting, '''and applies the new <code>volume</code>''' (ramp it over a few hundred ms rather than stepping). The server uses exactly this to turn a sound up as you walk toward its source: same <code>name</code>, same <code>key</code>, higher <code>volume</code>, <code>continue: true</code>. The server never re-sends an unchanged track, but honor the flag anyway.
 +
* '''<code>loops</code>''': <code>-1</code> is forever; area music arrives with <code>-1</code>.
 +
* '''<code>priority</code>''': a play halts lower-priority media while it runs.
 +
* '''<code>source</code>''' (not in the MCMP spec; this server adds it): whose sound it is, '''from where you stand''', so two people in the same room get different values for the same event. <code>self</code> (you did it), <code>group</code> (a groupmate did), <code>otherpc</code> (another player), <code>npc</code> (a mob), <code>ambient</code> (the environment: every play from an area file, music included). Set on spells, doors, locks, skills and your own level-up; absent on script sounds, the login track, and every stop. A per-source volume or mute is the intended use: mute <code>otherpc</code> and <code>npc</code> stealth sounds while keeping your own, for instance. Whether a targeted spell was aimed at you is NOT carried; the engine does not know it reliably at the point the sound is sent.
 +
* '''<code>tag</code>''': every play the engine builds carries one except a builder’s area track: <code>default</code> on area music drawn from the server’s default list (see below), <code>ambient</code> on every Sound: item from an area file, and on one-shots <code>door</code>, <code>lock</code>, <code>eat</code>, <code>drink</code> and <code>quaff</code> (someone in the room eating, drinking or quaffing a potion), <code>level</code> (your own level-up or era level, sent to you alone), <code>xp</code> (an experience award you were shown, sent to you alone), <code>spell</code> (a spell going off or fizzling where the caster stands), <code>skill</code> (a named skill landing or missing where its user stands), <code>combat</code> (one weapon noise per armed fighter per fight round), <code>death</code> (a death cry, heard in the victim’s room and the rooms one exit away), <code>shoot</code> (bow and gun shots, throws), <code>tradeskill</code> (reserved; no play carries it yet). Useful for a per-tag volume or mute; nothing else depends on it. Prefer the tag when present and fall back to the key rule without.
 +
* '''<code>fadeaway</code>''' on stop: fade over the smaller of the remaining track and <code>fadeout</code>, then stop.
 +
 +
Sources: builders trigger one-shot sounds and music from mob, room and
 +
object scripts (room-wide, everyone present with support gets the same
 +
message), the engine’s own one-shots for doors, locks, levelling,
 +
skills, shots and combat rounds (operator-editable lists on the server, so which file plays for
 +
a given event can change without notice, and one event may have several
 +
files it picks from), and area files declare background music and ambient sounds per
 +
room or per zone: on every room change you get only the difference, a play
 +
for what newly reaches you (or whose volume changed), and a <code>stop</code> with
 +
<code>fadeaway</code> for what no longer does (two seconds for music, one for a
 +
sound). Cache files by <code>url</code> + <code>name</code>; the names are path fragments
 +
and may contain subdirectories (<code>weather/rain.mp3</code>).
 +
 +
'''Fight music.''' The first time your character starts fighting or is
 +
attacked, the server sends <code>client.media.stop { &quot;key&quot;: &quot;area-music&quot;, &quot;fadeaway&quot;: true, &quot;fadeout&quot;: 2000 }</code> and a <code>client.media.play</code> with
 +
<code>&quot;key&quot;: &quot;fight-music&quot;</code>, <code>&quot;type&quot;: &quot;music&quot;</code>, <code>&quot;loops&quot;: -1</code>, <code>&quot;tag&quot;: &quot;fight&quot;</code>, <code>&quot;fadein&quot;: 2000</code>. It keeps playing while anyone in your room is
 +
fighting, including after you are rescued or knocked out, and five
 +
seconds after your room goes quiet or you leave it the server sends
 +
<code>client.media.stop { &quot;key&quot;: &quot;fight-music&quot;, &quot;fadeaway&quot;: true, &quot;fadeout&quot;: 2000 }</code> followed by the area or default track for your room as a normal
 +
play. Nobody who merely watches a fight gets it. The <code>fight</code> tag is in no
 +
category, so the category switches never drop it; use a per-tag volume
 +
if you want it quieter. Quitting or renting mid-fight sends the same stop
 +
with the other typed stops.
 +
 +
'''Login-screen music.''' If the operator has configured a track for it, the
 +
moment you list <code>Client.Media</code> on a fresh connection (before a character is
 +
in the game) you get the base url and a <code>client.media.play</code> with <code>&quot;key&quot;: &quot;login-music&quot;</code>, <code>&quot;type&quot;: &quot;music&quot;</code>, <code>&quot;loops&quot;: -1</code>. It plays through the
 +
banner, the account menus and character creation. On the first room
 +
placement the server sends <code>client.media.stop { &quot;key&quot;: &quot;login-music&quot;, &quot;fadeaway&quot;: true, &quot;fadeout&quot;: 2000 }</code> and the area track, if any, follows in
 +
the same breath. Every trip back to the menus brings it back: when your
 +
character quits or rents, the server sends typed <code>fadeaway</code> stops for the
 +
area music and every ambient sound, then the login play again, and the
 +
placement stop follows on the next login. Declaring <code>Client.Media</code> only
 +
after the character is in a room skips it until the next trip to the menus.
 +
 +
'''Default music.''' Where no area file gives a room music, the server plays
 +
a track from a per-era default list instead, under the same <code>&quot;key&quot;: &quot;area-music&quot;</code>, with <code>&quot;tag&quot;: &quot;default&quot;</code> so you can tell it from a builder’s
 +
track. One is picked at random when the player arrives and held through
 +
room and era changes until an area track takes over; leaving that area
 +
picks a fresh one. Three packages go with it, none with a reply: two
 +
client → server, one server → client:
 +
 +
{| class="wikitable"
 +
|-
 +
! Package
 +
! Body
 +
|-
 +
| <code>client.media.settings.set</code>
 +
| <code>{ &quot;defaultmusic&quot;: false }</code> turns default music off for this connection: a <code>fadeaway</code> stop for the current default and no more picks. <code>true</code> turns it back on and a track starts at once if nothing else reaches the room. The server does not remember it between connections; send it after <code>core.supports.set</code> on every connect. Area music, sounds, one-shots and the login track are untouched. Bad payloads are logged server-side and ignored. <code>{ &quot;categories&quot;: { &quot;combat&quot;: false, &quot;shooting&quot;: false } }</code> turns whole categories of one-shot off for this connection: the server never sends a play whose tag falls in a declined category (<code>skills</code>: <code>skill</code>; <code>spells</code>: <code>spell</code>; <code>combat</code>: <code>combat</code>, <code>death</code>; <code>shooting</code>: <code>shoot</code>; <code>tradeskills</code>: <code>tradeskill</code>; <code>level</code>: <code>level</code>, <code>xp</code>; <code>other</code>: <code>door</code>, <code>lock</code>, <code>eat</code>, <code>drink</code>, <code>quaff</code> and any play with no tag). The object replaces your whole choice each time: send only the names turned off, <code>{}</code> or no <code>categories</code> member is all on. Names are lower case and matched exactly. Unknown names and non-booleans are logged and skipped, the rest applied. Stops still arrive for anything that was playing. Not remembered between connections; send it after <code>core.supports.set</code> on every connect. Music, <code>default</code> and <code>ambient</code> are not categories; use your own switches for them.
 +
|-
 +
| <code>client.media.categories</code>
 +
| server to client, once per connection right after your Client.Media declaration: <code>[&quot;skills&quot;,&quot;spells&quot;,&quot;combat&quot;,&quot;shooting&quot;,&quot;tradeskills&quot;,&quot;level&quot;,&quot;other&quot;]</code>, the categories of one-shot the server will let you decline, in a fixed order. Render one switch per name you receive; a new category needs no client release.
 +
|-
 +
| <code>client.media.next</code>
 +
| <code>{ &quot;name&quot;: &quot;&lt;file&gt;&quot; }</code> asks for a different default track; <code>name</code> is the file from the last default play and may be omitted. Works only while a default track is playing: enable the control after a play with <code>&quot;tag&quot;: &quot;default&quot;</code>, disable it on any play without that tag or a stop for <code>area-music</code>. The new track arrives as a normal <code>client.media.play</code>. When the era’s list has a single track nothing arrives; that is not an error.
 +
|}
  
 
== 5. What the server pushes ==
 
== 5. What the server pushes ==
Line 666: Line 1,973:
 
| <code>room.info</code>
 
| <code>room.info</code>
 
| every room change and LOOK
 
| every room change and LOOK
 +
|-
 +
| <code>client.media.categories</code>
 +
| once per connection, right after your Client.Media declaration: the one-shot categories you may decline (§4.16)
 
|-
 
|-
 
| <code>char.items.update</code>
 
| <code>char.items.update</code>
Line 678: Line 1,988:
 
| <code>char.colors</code> (full snapshot)
 
| <code>char.colors</code> (full snapshot)
 
| once at login/reconnect, then whenever the player’s color config changes
 
| once at login/reconnect, then whenever the player’s color config changes
 +
|-
 +
| <code>comm.channels</code> (full snapshot)
 +
| once at login/reconnect, then whenever the channel table changes (created, deleted, renamed, modified)
 +
|-
 +
| <code>comm.message</code>
 +
| a <code>gtell</code>/<code>ptell</code>/<code>tell</code>/<code>channel</code> message you would have received as text, if you negotiated <code>&quot;gmcp&quot;</code> or <code>&quot;both&quot;</code> for that kind (§4.12) — conditional on <code>comm.delivery.set</code>, unlike everything else in this table; <code>&quot;tell&quot;</code> also arrives while AFK-hidden even with no text line, see §4.12
 +
|-
 +
| <code>map.ansi.view</code>
 +
| opt-in: after every <code>room.info</code> push, once you have sent <code>map.ansi.subscribe</code> (§4.15)
 
|-
 
|-
 
| <code>logging.error</code>
 
| <code>logging.error</code>
 
| your request couldn’t be handled
 
| your request couldn’t be handled
 +
|-
 +
| <code>client.media.*</code>
 +
| only after you list <code>&quot;Client.Media 1&quot;</code> in <code>core.supports.set</code>/<code>.add</code> (§4.16): the base url once, the login-screen track if one is configured, then plays/stops from scripts and on area changes
 
|}
 
|}
  
Line 691: Line 2,013:
 
# Request the static tables you care about: <code>skills.all</code>, <code>spells.all</code>, <code>words.all</code>, <code>runes.all</code>, <code>abilities.all</code>, <code>tradeskills.all</code>, <code>moods.all</code>.
 
# Request the static tables you care about: <code>skills.all</code>, <code>spells.all</code>, <code>words.all</code>, <code>runes.all</code>, <code>abilities.all</code>, <code>tradeskills.all</code>, <code>moods.all</code>.
 
# Request your character’s state: <code>char.score</code>, <code>char.status</code>, <code>char.skills</code>, <code>char.spells</code>, <code>char.words</code>, <code>char.runes</code>, <code>char.abilities</code>, <code>char.tradeskills</code>, <code>char.factions</code>, <code>char.moods</code>, <code>char.inventory</code>, <code>char.equipment</code>, <code>group.info</code>, <code>char.journal</code>.
 
# Request your character’s state: <code>char.score</code>, <code>char.status</code>, <code>char.skills</code>, <code>char.spells</code>, <code>char.words</code>, <code>char.runes</code>, <code>char.abilities</code>, <code>char.tradeskills</code>, <code>char.factions</code>, <code>char.moods</code>, <code>char.inventory</code>, <code>char.equipment</code>, <code>group.info</code>, <code>char.journal</code>.
−
# Let the pushes keep <code>prompt</code>, <code>room</code>, <code>inventory</code>/<code>equipment</code> (via <code>char.items.update</code>), <code>journal</code>, and <code>colors</code> current (<code>char.colors</code> arrives on its own at login); re-request anything else when you want it fresh (e.g. <code>group.info</code> on a timer, <code>char.factions</code> after questing).
+
# Let the pushes keep <code>prompt</code>, <code>room</code>, <code>inventory</code>/<code>equipment</code> (via <code>char.items.update</code>), <code>journal</code>, <code>colors</code>, and the channel <code>catalog</code> current (<code>char.colors</code> and <code>comm.channels</code> both arrive on their own at login); re-request anything else when you want it fresh (e.g. <code>group.info</code> on a timer, <code>char.factions</code> after questing).
 
# Fetch helpfiles on demand with <code>help.topic</code> — no need to prefetch; entries resolve in one round trip. Fetch journal-entry detail on demand with <code>char.journal.entry</code>, per vnum, when a quest pane opens.
 
# Fetch helpfiles on demand with <code>help.topic</code> — no need to prefetch; entries resolve in one round trip. Fetch journal-entry detail on demand with <code>char.journal.entry</code>, per vnum, when a quest pane opens.
 +
# If you show the game’s own map sketch, send <code>map.ansi.subscribe</code> with your panel’s size (§4.15) and re-send it whenever that panel is resized.
  
 
== 6. Accepted no-ops ==
 
== 6. Accepted no-ops ==
  
−
<code>core.hello</code>, <code>core.supports.set</code> / <code>.add</code> / <code>.remove</code>,
+
<code>core.hello</code>, <code>core.keepalive</code>, <code>core.ping</code>, and <code>external.discord.hello</code>
−
<code>core.keepalive</code>, <code>core.ping</code>, and <code>external.discord.hello</code> are
+
are accepted without error but do nothing. <code>core.supports.set</code> / <code>.add</code> /
−
accepted without error but currently do nothing. You may send them
+
<code>.remove</code> are read for exactly one entry, <code>Client.Media</code> (§4.16), and
−
(many client GMCP stacks do automatically); today they don’t change
+
otherwise ignored: listing or omitting any other package there does not
−
what the server broadcasts. A future subscription model may start
+
change what the server broadcasts. A future subscription model may honor
−
honoring <code>core.supports.*</code>.
+
the rest.
  
 
== 7. Errors ==
 
== 7. Errors ==

Latest revision as of 08:33, 1 October 2026

This document describes LegendMUD’s GMCP (Generic MUD Communication Protocol) support from the client’s point of view: how to enable it, what you can request, what the server pushes on its own, and the exact shape of every payload. It is written for people building or scripting MUD clients (Mudlet, TinTin++, custom clients, etc.). No knowledge of the server code is needed — or useful — here.

In-game, HELP GMCP covers the basics.


1. Enabling GMCP[edit]

GMCP is telnet option 201. On connect the server offers it:

server → client:   IAC WILL 201
client → server:   IAC DO   201     (enables GMCP)
client → server:   IAC DONT 201     (disables GMCP)

Most scriptable clients handle this negotiation for you and expose GMCP events directly. Nothing is sent over GMCP until you answer IAC DO 201.

All GMCP traffic — both directions — is framed as telnet sub-negotiation:

IAC SB 201  <package name> [<JSON payload>]  IAC SE

A single space separates the package name from the JSON when a payload is present.

2. Making requests[edit]

Send the package name, optionally followed by JSON arguments:

char.score
object.info {"oid":"0x1a2b3c4d5e6f7890"}

Rules:

  • Package names are case-insensitive (Char.Score works), and must be dotted — at least word.word.
  • JSON keys are case-sensitive, in both requests and replies.
  • Most packages take no arguments; any request body they receive is ignored. The exceptions are object.info and char.inventory (an oid), help.topic (keywords), char.journal.entry (vnum), char.skills.query (a slot), comm.delivery.set (a kind → mode object, §4.12), and map.ansi.view / map.ansi.subscribe (a panel size, §4.15).
  • Requests are size-capped: package name up to 49 characters, JSON body up to 399 characters.
  • A malformed request, unknown package, or invalid JSON gets a logging.error reply (see §7) rather than silence.

Every reply arrives as its own GMCP message, tagged with the package name. One request can produce several messages (spells.all sends four).

3. Reading the reference: conventions[edit]

  • Request-only — sent only when you ask. Most packages.
  • Pushed — sent by the server when game state changes, whether or not you asked (§5 lists them).
  • *.all packages are static reference data. They describe the game, not your character, and do not change during play. Request each once per session, cache it, and join the per-character packages against it by id.
  • Ids are stable for your session but not guaranteed across server reboots for every package (each entry below says which field is the durable identifier). The safe pattern is: fetch *.all once per login, join on id from there.
  • Empty means empty. Per-character list packages always reply, even when the answer is “none” — a non-chanter asking for char.spells gets {"spells": []}, not silence. You can rely on a reply to every valid request.
  • All numbers are raw integers — no display formatting, no commas.

4. Package reference[edit]

4.1 Character basics[edit]

char.prompt — pushed on every prompt, also requestable[edit]

An object keyed by prompt tokens: hit points, mana, movement, position, room, gold, opponent condition, and everything else the text prompt can show. Immortal-only tokens are omitted for mortals.

Every token is always present, whether or not it is in your own prompt format. You never need another package to read a stat that has a prompt token — spirit is S5/stat_spirit, your name is n/name, and so on. char.status is for conditions, affects and timers, not vitals.

The full key list, generated from the server’s token table:

Short key Long key (gmcplongpromptkeys) JSON type Meaning
a afk_status bool AFK status
A align_value int Alignment
ak area_key string Area Keyword
am area_maintainer string Area Maintainer
an area_name string Area Name
b alignment string Alignment
bl block int Block chance
c ac int Armor rating (ac)
ch chi_current int Chi (current); present only while martial arts is enabled; absent otherwise
CH chi_max int Chi (maximum); present only while martial arts is enabled; absent otherwise
co concentration int Concentration
d dodge int Dodge chance
dc damcap int Damage cap
dr damroll int Damroll
ds damage_shield int Damage shield
f fighting_name string Fighting Target
fc fighting_condition string Target Condition
ff fighting_fighting string Target’s Target
fh fighting_health string Target’s Health
g gold int Gold
h hit_points int Hit Points (current)
H max_hit_points int Hit Points (maximum)
hr hitroll int Hitroll
ia arcane_mastery bool Arcane Mastery
k afk_tells int # of AFK messages
l level int Level
L leader string Leader
m mana int Mana (current)
M max_mana int Mana (maximum)
mc combat_mood string Combat mood
mi mitigation int Mitigation
mr mana_reduction int Mana Reduction
ms social_mood string Socials mood
mt temporary_mood string Talk mood
mw walk_mood string Walk mood
n name string Character Name
p position string Position
P pk_damage int PK damage
pa parry int Parry Bonus
pr prestige int Prestige
v move int Move (current)
V max_move int Move (maximum)
vi area_percent_explored number Visited Info
ra ranged_accuracy int Ranged Accuracy
rc current_rent int Current Rent
rg rage int Rage
rm max_rent int Max Rent
rs rent_status string Rent Status (under/over)
rf rent_free int Free Rent
sc spell_crit int Spell Crit
sd spell_damroll int Spell Damroll
R0 raw_strength int Raw Strength
R1 raw_mind int Raw Mind
R2 raw_dexterity int Raw Dexterity
R3 raw_constitution int Raw Constitution
R4 raw_perception int Raw Perception
R5 raw_spirit int Raw Spirit
S0 stat_strength int Stat Strength
S1 stat_mind int Stat Mind
S2 stat_dexterity int Stat Dexterity
S3 stat_constitution int Stat Constitution
S4 stat_perception int Stat Perception
S5 stat_spirit int Stat Spirit
t time string Game Time
T system_time string System Time
w wimpy int Wimpy
W wary int Agg/Wary
wc weight string Current Weight
wm max_weight string Maximum Weight
wt wait int Current Wait
Wh hp_watching int Hit Point WATCH target’s HP
Wm mana_watching int Mana WATCH target’s MANA
Wv move_watching int Move WATCH target’s MOVE
WH watching_hp string Hit Point WATCH target
WM watching_mana string Mana WATCH target
WV watching_move string Move WATCH target
x exp int Experience (current)
X xp_to_level int Experience to Next Level
1 percent_hp int Hit Points (percentage)
2 percent_mana int Mana (percentage)
3 percent_move int Move (percentage)
4 percent_xp int Experience to Next Level (percentage)
5 era_exp_curr int Era Experience (in current era)
6 era_exp_to_level int Era Experience to Next Era Level
$ newline string Adds a line feed into the prompt; prompt-format control token; carried for completeness
@ at string A literal ‘@’; prompt-format control token; carried for completeness
! mail string MAIL if you have mail waiting
#0 timer_0 string Timer with the shortest duration
#1 timer_1 string Timer with the second shortest duration
#2 timer_2 string Timer with the third shortest duration
e era string Era
i wizinvis_status string Wizinvis Status; immortals only, absent for mortals
r room int Room Vnum; immortals only, absent for mortals
y yellzone int Yellzone; immortals only, absent for mortals

Chi is among them: the chi_current/chi_max tokens (short codes ch and CH) are ordinary mortal tokens. They are not in the default text prompt — a player adds @ch/@CH to their own prompt format to see them there. They are present on char.prompt/char.prompt.delta only when the martial arts system is enabled on this server; while it is off, both keys are absent entirely (not sent as 0 or null), the same present-only-when-enabled rule char.score.vitals.chi/vitals.maxChi (§4.1 below) already follows. Clients must tolerate their absence — check for the key before reading it, don’t assume it’s always there.

By default the keys are the short prompt codes. Turn on the gmcplongpromptkeys config option in-game to get verbose, self-describing keys instead — recommended for new clients. See HELP GMCPLONGPROMPT.

char.prompt.delta — opt-in, pushed only[edit]

Off by default — nothing about char.prompt changes until you ask. Opt in with:

char.prompt.delta { "on": true }

Once opted in:

  • Every prompt render — whether you typed something or game output re-rendered it — arrives as char.prompt.delta containing only the keys whose values changed since the last message you were sent. Do not expect a full after your own commands.
  • Nothing changed → nothing arrives at all, not even an empty {}. Don’t use either package as a heartbeat — if your UI needs a “prompt happened” signal, use the telnet prompt line instead.
  • Fulls are rare and always meaningful. You get a full char.prompt only: right after opting in (your starting baseline), when you request one, after SHOWPROMPT, or when the server must rebase you (reconnect, key-style toggle, immortal-status change, character change). If you want a full, ask for it.

Send char.prompt.delta { "on": false } to go back to full-every- render. The setting is per connection and does not persist — re-send it every session. The first prompt after opting in is always a full char.prompt.

Merging deltas: keep a cumulative baseline — your last full char.prompt, updated by every char.prompt.delta since — and apply each delta as a key-by-key overwrite; your model then always matches the server’s. No key ever disappears from the schema — a key absent from a delta simply hasn’t changed. A value that goes away (e.g. f/fighting_name when combat ends) arrives as JSON null rather than being omitted — treat null as an ordinary value, in both directions (null → value and value → null are both changes). Deltas use whichever key style (short or gmcplongpromptkeys) your connection is set to, same as char.prompt; toggling that setting mid-session forces the next message to be a full char.prompt under the new names.

Resync any time: request char.prompt and you get a full package; the server resets its baseline to match, so the deltas that follow are relative to that full — use this if you ever suspect drift. A full can still arrive unrequested (the rebase cases above) and always replaces your entire prompt state; never diff a full against your model, just adopt it.

Values in a delta are byte-identical to what the same key would carry in a full char.prompt — same types, same formatting, only the selection of keys differs. Immortal-only keys follow the same present-for-imms/absent-otherwise rule as fulls.

char.score — request-only[edit]

A single snapshot of the full score sheet, grouped:

{
  "identity": {
    "name": "Merlin", "pretitle": "the Great", "title": "Mighty Wizard",
    "posttitle": "of the Tower", "sex": "Male", "age": 156,
    "played": 5000000, "hometown": "Midgaard", "clan": "Archmages",
    "level": 25, "align": 450, "practices": 15, "redemptions": 0,
    "pvp": "PKE, PKOK", "area": "The Midgaard Forest"
  },
  "vitals":  { "hp": 180, "maxHp": 250, "mana": 400, "maxMana": 500,
               "move": 120, "maxMove": 150, "chi": 30, "maxChi": 50 },
  "stats":   { "str": 18, "min": 16, "dex": 17, "con": 16, "per": 14,
               "spi": 15, "prestige": 2 },
  "combat":  { "ac": -45, "hitroll": 28, "damroll": 35, "damcap": 400,
               "spellCrit": 12, "spellDam": 25, "concentration": 10,
               "manaReduction": 15, "damageShield": 8,
               "mitigation": { "cur": 120, "cap": 300 },
               "parry": 5, "wary": 0, "wimpy": 0, "rangedAccuracy": 0,
               "weaponSpeed": 2, "critChance": 8, "critBonus": 25,
               "meleeDamMod": 0, "dodge": 12, "block": 15 },
  "regen":   { "hp": 50, "mana": 80, "move": 40 },
  "xp":      { "total": 2500000, "toLevel": 150000, "eraSplitPct": 50,
               "era": {
                 "ancient":    { "exp": 750000, "levels": 15, "used": 10, "free": 5 },
                 "medieval":   { "exp": 1000000, "levels": 20, "used": 15, "free": 5 },
                 "industrial": { "exp": 0, "levels": 0, "used": 0, "free": 0 }
               } },
  "carry":   { "items": 25, "weight": 450, "maxWeight": 500,
               "rent": 1200, "maxRent": 2000 },
  "gold": 50000,
  "bank": 100000,
  "resistances": [ { "name": "fire", "value": 15 },
                   { "name": "cold", "value": -10 } ]
}

Nullable fields — treat null as “not applicable”:

  • identity.clan — no clan; identity.area — unknown area.
  • xp.toLevel — null above level 50 (no further levels). Below 50 it is XP to the next level; at exactly 50, XP to redeem.
  • xp.eraSplitPct and every xp.era.*.exp — null below level 50 for non-remorts (the text SCORE shows ? there; the detail is hidden until you reach 50).
  • carry.maxRent — null above level 50 (unlimited).

sex is "Male", "Female", or "Other". pvp is "PKE", "PKOK", "PKE, PKOK", or "none". resistances has one entry per nonzero damage modifier; empty array when you have none.

vitals.chi/vitals.maxChi are present only when the martial arts system is enabled on this server; while it is off they are omitted entirely (not null), so vitals has 6 members instead of 8. Clients must tolerate their absence rather than assume the keys always exist. The sample above assumes the system is enabled; with it off, drop chi/maxChi from the vitals object you’d expect to receive.

Conditions, affects, and timers are deliberately not here — see char.status.

char.status — request-only[edit]

{ conditions, affectedBy, timers, charmies }, each an array or null. Three sub-packages return one slice each, in the same shapes (none of them includes charmies):

  • char.status.conditions → { "conditions": ["hungry", …] | null }
  • char.status.affectedby → { "affectedBy": [{ "time", "name", … }, …] | null } — time is remaining seconds; stacked copies of the same affect collapse into one entry. Each entry can also carry applies, grants, dmgMods, resists, immunities, and susceptibilities — see below. The affectedBy member embedded directly in char.status is built the same way and carries identical entries.

Affect payload — additive, no opt-in, no new package. time and name are unchanged; disposition is always present; every other new key is omitted when it would be empty, so a { "time", "name", "disposition" } entry is still normal and common (e.g. a flag-only or otherwise-inert affect). Example of a fully-loaded entry:

{
  "time": 512,
  "name": "armor of faith",
  "disposition": "beneficial",
  "applies":          { "ac": -20, "saving_spell": -2 },
  "grants":           [ "sanctuary" ],
  "dmgMods":          { "slash": 10 },
  "resists":          [ "fire" ],
  "immunities":       [ "poison" ],
  "susceptibilities": [ "cold" ]
}
  • disposition — always one of "beneficial", "detrimental", or "neutral"; how the game classifies the affect (the same classification that colors the affect’s name green/red in the text affects list). If an affect somehow carries both classifications, detrimental wins. Exactly these three strings — but treat an unknown value as neutral rather than erroring, per the usual forward-compatibility rule. Beneficial affects are player-removable: a UI may offer a remove control on "beneficial" entries that sends removeaffect <name> (the entry’s name verbatim) — word the player-facing confirmation Remove <name>?, not the command name. Don’t offer it on detrimental/neutral entries; the server refuses those.

  • applies — object, numeric stat modifiers this affect currently contributes. Keys are stat names (strength, saving_spell, hp_regen, ac, …); values are signed JSON ints (negative values are normal and mean the same thing they do everywhere else — e.g. lower ac is better).

  • grants — array of flag names this affect grants (e.g. "sanctuary", "detect_invis").

  • dmgMods — object of damage-type → signed percent modifier, e.g. { "slash": 10 } means +10% slash damage. Its damage-type names heavily overlap with resists / immunities / susceptibilities (flame, poison, slash, …) but the sets are not identical — each key’s vocabulary comes from its own server table. Don’t build one fixed shared list; treat each key’s names as its own open vocabulary and ignore unknowns.

  • resists / immunities / susceptibilities — arrays of damage-type names this affect resists, grants immunity to, or makes the character more susceptible to.

  • All key/value vocabularies are mechanical lowercase of the game’s internal names — the same information STATUS full shows a player for that affect, just structured instead of prose. Key names are the server’s mechanical internal names, which sometimes differ from the friendly labels STATUS prints (e.g., mod_buf_hitroll is the HITROLL line). This is forward-compatible: new names can appear with no protocol change, so ignore any key or value you don’t recognize rather than treating it as an error.

  • If an affect contributes to the same stat/damage-type more than once (e.g. two stacked sources folded into one entry), the value you receive is already the summed total — you never need to add entries together yourself.

  • char.status.timers → { "timers": [{ "name", "time" }, …] | null } — skill/ability reuse timers.

  • charmies (in the composite only — there is no sub-package for it) → one entry per charmed pet standing in your room, or null when none is with you. You only get a pet’s status while it is in your room, matching the in-game limitation; a pet elsewhere simply drops out of the array, so treat absence as “not visible”, not “gone”. Entry shape:

    {
      "name": "guard dog pet",
      "longName": "a guard dog",
      "conditions": [ "hungry" ],
      "affectedBy": [ { "time": 512, "name": "armor", "disposition": "beneficial" } ],
      "timers": null
    }

    name is the same keyword string char.group members carry — use it to correlate the two packages. longName is the display name. conditions, affectedBy, and timers have exactly the shapes documented above for the player’s own slices (including the affect payload keys and null when empty).

char.offer — request-only[edit]

Mirrors the OFFER command: { "items": [{ "name", "rent", "count" }, …] | null, "totalRent", "totalItems" }.

char.colors — request, login push, and config-change push[edit]

The player’s own condition colors (the mud’s color slots 16–20) in compiled form, so you can tint hp/mana/mv gauges, prompt tiles, and group bars exactly the way the player’s text prompt does. Request with an empty body; also pushed once at login/reconnect and whenever the player’s color config changes (color set / color scheme / color set default). Every message is a full snapshot — adopt it wholesale, never merge. Always complete (scheme defaults fill slots the player never touched), never {}.

{ "condition": {
    "full":     { "fg": 2,  "bg": -1, "attrs": [] },
    "low":      { "fg": 10, "bg": -1, "attrs": [] },
    "medium":   { "fg": 3,  "bg": -1, "attrs": ["bold"] },
    "bad":      { "fg": 1,  "bg": -1, "attrs": ["bold"] },
    "critical": { "fg": 9,  "bg": -1, "attrs": ["blink"] }
} }

fg/bg are classic 16-color palette indices (0–7 normal, 8–15 bright) or -1 for terminal default; render through the same palette your output pane uses for ANSI SGR. attrs draws from "bold", "dim", "italic", "underline", "blink", "reverse" — same semantics as the SGR attributes in output text (bold can appear alongside a 0–7 fg instead of a bright 8–15 fg; render both like the output pane would; reinterpreting blink as a gentle pulse is fine). Ignore keys you don’t recognize, at both the group and color-object level — more groups may be added without a version bump.

Pick the tier with the server’s integer arithmetic, in this order, on the same cur/max you already have from vitals data — do NOT reformulate as percentages (rounding disagrees at the boundaries):

cur >= max            -> full
cur <= max / 10       -> critical
cur <= max / 4        -> bad
cur <= max * 2 / 3    -> medium
otherwise             -> low

If max <= 0 or the values are hidden, the mud shows ?? uncolored — fall back to your untinted rendering rather than guessing a tier.

char.colors also carries a "channel" group, alongside "condition", at the same top level:

{ "condition": { ... },
  "channel": {
    "bracket": { "fg": 7,  "bg": -1, "attrs": [] },
    "name":    { "fg": 5,  "bg": -1, "attrs": [] },
    "speaker": { "fg": 12, "bg": -1, "attrs": [] },
    "text":    { "fg": 5,  "bg": -1, "attrs": [] }
  }
}

Four entries, the same {fg, bg, attrs} shape as condition’s groups — the player’s standard channel palette: bracket (the [ ] around the channel name, and the speaker colon), name (the channel name inside the brackets), speaker (the speaking character’s name), and text (the message body). Same delivery as the rest of char.colors: full snapshot at login/reconnect, on request, and pushed automatically whenever the player reconfigures any of these four colors in-game.

Advisory, not authoritative. These are the colors of the STANDARD channel format’s slots (the ones the default format, and the @C/@N/@M macros a custom format can use, hardcode). A player running a fully hand-rolled custom chan_format that skips those macros may render channel messages differently. The palette is for your own UI chrome — a channel list, tab colors, a compose box — never to re-render comm.message’s line, which stays the exact rendering ground truth for every subscriber regardless of this group’s values.

Full contract: docs/updates/2026-07-15-gmcp-char-colors-client-spec.md (condition) and docs/updates/2026-08-06-gmcp-channel-catalog-handover.md (channel group and comm.channels, below).

char.colors also carries a "slots" group, a third top-level member alongside "condition" and "channel": the player’s entire color table, all 58 u-color slots, keyed by the wire’s own u-code numbers as unpadded decimal strings ("0" .. "57", matching how you already parse \|U7/\|U10 numerically out of a line). Values are the same resolved {fg, bg, attrs} shape as condition and channel.

{ "condition": { ... },
  "channel": { ... },
  "slots": {
    "0":  { "fg": 8,  "bg": -1, "attrs": [] },
    "1":  { "fg": 9,  "bg": -1, "attrs": [] },
    "7":  { "fg": 8,  "bg": -1, "attrs": [] },
    "8":  { "fg": 5,  "bg": -1, "attrs": [] },
    "9":  { "fg": 12, "bg": -1, "attrs": [] },
    "10": { "fg": 5,  "bg": -1, "attrs": [] },
    ...
    "57": { "fg": 1,  "bg": -1, "attrs": [] }
  }
}

This is the join key the channel/condition groups don’t carry: slots["7"] .. slots["10"] are value-identical to channel.bracket/name/speaker/text, and slots["16"] .. slots["20"] are value-identical to condition.full/low/medium/ bad/critical (verified byte-equal on the wire). Use slots to color every \|Uxx marker your own line-parsing turns up, including markers a hand-rolled CHANFORMAT emits that never show up in the channel group at all — condition/channel stay the semantic labels for their four/five familiar roles; slots is the general lookup table underneath them.

Semantics pinned for this group:

  • Delivery. Same as the rest of char.colors: a full snapshot at login/reconnect, on request, and re-pushed whole (coalesced, at most once per pulse) whenever the player changes any color slot. Replace your copy wholesale on every message; never diff or merge.
  • All 58 slots are always present, from this server. Every slot always has a compiled color (defaults fill any slot the player never touched), so an absent slot cannot occur talking to this server. An absent slot means “no opinion, render that segment plain” — that fallback exists for older servers that don’t send slots at all, not for anything this server can produce.
  • Slot numbers are stable. They’re a compile-time, append-only enum on the server — stable within a session, stable across sessions, stable across characters — and change only when the mud itself ships a new release that appends a slot. Keep replacing on every snapshot regardless; this only affects how hard you lean on caching between snapshots.
  • Advisory, same doctrine as channel. line is the ground truth for exact rendering. Where a comm.message frame carries its own color member, that member stays authoritative for its segment; slots fills in everything color doesn’t cover.

4.2 Room and world[edit]

room.info — pushed on movement and look, also requestable[edit]

{ "name": "...", "desc": "...", "area": "...", "vnum": 3001,
  "type": "indoors", "is_inn": false,
  "echo": { "zone": "cave", "room": "small_indoors" },
  "exits": [ { "dir": "north", "door": "closed",
               "to_name": "A Quiet Lane", "to_vnum": 3005 }, … ] }
  • Darkness: if your character can’t see, name and desc are both "It is too dark to see..." and exits is omitted entirely.

  • Blindness: exits is omitted while blind, whatever the light.

  • area is present only when the room belongs to a known area.

  • vnum is present for every room. Inside an instance it is the live slot vnum, always in 85000-89999; that range is how you tell an instance room from a world room. Slot vnums are recycled between openings, so never key a saved map on one.

  • instance is present only inside a live instance: { "id": "62a86aa50d3c3c8c", "room": 13314, "name": "Maharaurava" }. id is an opaque string, unique to this opening and never reused. room is the origin vnum in the source block the instance was copied from. name is the builder’s name for the instance and is omitted when unset. Key a saved instance map on name, with rooms keyed on room; when there is no name, map the instance for the session only and drop it when id changes or the block disappears. The block appearing is your entry signal; the eviction look, which carries no block, is your exit signal. There is no separate push.

  • On exits, to_vnum is always present, slot vnums included. When the destination is a live instance room the exit also carries to_inst, the destination’s origin vnum, so you can draw the edge in source space before walking it. An exit that leaves the instance has a real to_vnum and no to_inst.

  • random_stamp (integer, epoch seconds) is present only on rooms the game’s random map generator has touched since boot. Its exits are rewired each time the generator runs, so hold such a room in a session-only map layer, never in the saved map. Every room of one generator run carries the same value; when a room arrives with a different stamp than you hold for it, drop that room’s cached edges (and, optionally, those of every room sharing the old stamp) and rebuild from the live exits. Absent field = static room.

  • type is always present: one of "indoors", "underwater", "aerial", "water", "outdoors".

  • is_inn is always present and is character-dependent: whether you could rent here right now (false while fighting, for example). It is not a fixed property of the room. Immortals always see false.

  • exits mirrors the in-game autoexit line, including its secrecy rules — hidden or unrevealed exits simply don’t appear. Per exit: dir always; door only for door exits ("open", "closed", or "locked"); to_name only when the destination’s name is visible.

  • echo is present only when a builder has described the room’s acoustics, as an object with up to two string members: zone, the word for the room’s yellzone, and room, the word for the room itself. Use room when present, else zone, else treat the room as none. An absent member is none. The words:

    word meaning
    none dry, no processing; also what an unset level means
    small_indoors a room, tavern, hut
    large_indoors a big interior, warehouse, temple nave
    hall stone cathedral scale, long bright tail
    cave long dark tail
    outdoor_small alley, courtyard, forest clearing
    outdoor_large open field, plain, sea
    underwater muffled, the one that filters the dry signal too

    The member rides every room.info outside an instance, the dark form included (instance rooms never carry it); what a word sounds like is yours. Apply it to the Client.Media sound half; music is usually left dry.

4.3 Objects, inventory, equipment[edit]

Objects are identified by OID — an opaque 64-bit id rendered as a hex string with a mandatory 0x prefix (e.g. "0x1a2b3c4d5e6f7890"). Always send it back exactly as you received it, prefix included. OIDs are how the three packages below link together.

You can only look up objects your character can actually see — the same visibility rules as in-game. An unknown, invalid, or unseen OID gets { "oid": "<what you sent>", "found": false }.

object.info — request with {"oid": "..."}[edit]

Full detail for one object. The reply always echoes the requested oid. When found and visible:

  • Core fields: oid, name (keywords), short, desc, type (item-type name), weight, rent, size, ac, timer. timer is the ticks until the item decays, -1 for a permanent item; a tick is 90 real seconds and two ticks make a mud hour, so do not label it as hours. Every change to it reaches you through char.items.update (section on pushes), so a countdown can be re-synced from each push.
  • condition: { "dam", "damMax" } — only when the item has a damage / repair ceiling.
  • flags[] — every item flag set on the object, present only when at least one is set. Names are the lowercase flag words: "glow", "magic", "invis" (the item is invis but you can see it anyway — style it accordingly), "no_repair", "no_backstab", "unique", and so on. New flags appear automatically as the game adds them, so ignore names you don’t recognize.
  • wearFlags[] — wear-position names.
  • A per-type block keyed by the item type, with named fields for that type. Nearly every item type in the game has one; the ones you’ll see most:
    • weapon: { attack, maxDam, quality, speed } — speed is the effective base speed factor. Weapons with an on-hit spell add spellChance, spell, spellLevel; weapons with a secondary attack add secondaryAttack, secondaryMaxDam (and secondarySpellChance/secondarySpell/secondarySpellLevel when the secondary carries a spell). Plain weapons omit all of those.
    • armor: { acApply, material }
    • container: { capacity, capWeight, capSize, flags, keyVal, weightReduction }
    • gun: { numDice, diceSize, charges, maxCharges, ammoType, jamChance, explodeChance, accuracy }, ammo: { ammoType, shots[, affect] }
    • light, drink, food, wand, staff, scroll, potion, pill, money, key, oil, powder, rune, spelltool, spellbook, instrument, fountain, furniture, boat, bundle, vehicle, currency, currencyPile, tool, fuel, manaStore, map, lock, board, note, seeds, bandage, medicament, medicalTool, dressing, portal, attack, grenade, corpse (NPC corpses: { mobVnum, undead, skinVnum }), and a few more — every field is a named integer (or string where noted), so render what you receive.
    • Item types without an interpreted block (decorative/misc items, and a few whose values are internal state) simply omit it.
  • affects[] — [{ "stat", "mod" }, …], plus { "stat": "dmgmod", "mod", "pct" } entries for damage-modifier affects.
  • use{} — only on an item that can be USEd for a spell: { "spell", "spellName", "recycleInterval", "recycleLeft", "wear", "selfOnly" }. spellName is the printable name; spell is the same number the weapon block uses. recycleInterval is the cooldown in seconds and recycleLeft the seconds until the item is ready again (0 = usable now; count it down client-side or re-request). wear true means it must be worn to use; selfOnly true means it always targets you. The spell level is not sent.
  • flavor{} — only on a drink container or fountain a druid has flavored: { "spell", "spellName", "spellLevel", "chance", "leftSeconds" }. spell and spellName as in use{}; chance the percent chance a drink casts it; leftSeconds the real seconds until the flavor wears off, accurate to one tick, count it down like recycleLeft. The block disappears when the flavor expires, the container is emptied, or the character rents; each of those reaches a carried item through char.items.update, so drop it when the pushed item has no flavor. Fountains also still carry the raw spell2* slots in their per-type block; prefer flavor{}.
  • props{} — a whitelisted subset of the object’s key/value properties, present only when the server lists keys in its GMCP_OBJECT_PROPS setting and the object carries one of them. The default list is empty, so expect no props{} at all unless the server has opted keys in.

char.inventory — request, two modes[edit]

  • No arguments → your base inventory (held, unworn items): { "items": [ { …object detail… }, …] }. Each row is a complete object.info detail object — no per-item follow-up fetch needed.
  • {"oid": "<container>"} → that container’s direct contents: { "oid", "items": [ … ] }, same full-detail entry shape. A closed container answers { "oid", "closed": true, "items": [] } (same rule as look in). Not found / not visible → { "oid", "found": false }.

Tell the two reply modes apart by the oid key: container replies have it, base-inventory replies don’t.

Paging: replies carry at most 100 rows. A capped reply adds "more": true and "offset": <n>; re-send the same request with that offset added (e.g. {"oid": "…", "offset": 100}) to get the next page, until a reply arrives without more. Most inventories fit in one page.

To walk someone’s whole carry tree, request base inventory, then request each container row’s oid as you (or the user) open it — one container level per request.

char.inventory is request/reply only — it never pushes. For live updates as your inventory changes during play, see char.items.update below.

char.items.update — pushed only, coalesced per moment[edit]

Tells you about every object-state change on your character’s person: picked up, dropped, given, worn, removed, moved in or out of a carried container, destroyed, and (over time) in-place changes like charges and condition. Changes are coalesced server-side — no matter how many things happen to your stuff in one game moment, you get at most one frame describing the net result, per moment. wear all, remove all, and death each arrive as one message with final state, not a hail of per-item deltas.

{ "changed": [
    { "loc": "inv",                    "item": { …object detail… } },
    { "loc": "worn:head",              "item": { … } },
    { "loc": "in:0x1a2b3c4d5e6f7890",  "item": { … } }
  ],
  "removed": [ "0xaabbccddeeff0011" ],
  "more": true
}
  • item is exactly an object.info detail object — the same one char.inventory rows and char.equipment items use.
  • loc says where the object now lives: "inv" (carried, unworn, top level), "worn:<slot>" (the same slot vocabulary as char.equipment, below), or "in:<container-oid>" (direct container only — for nested bags, reconstruct the tree by following each object’s own loc oid).
  • removed — oids no longer anywhere on your character. Dropped, given away, put into a container that isn’t carried, or destroyed: all identical here. It means “off your person,” not “gone from the world.” Removal of an oid you’ve never seen is a no-op — silently ignore it, never error (an item picked up and dropped in the same moment can arrive only as a removal).
  • An oid appears in exactly one of changed/removed per frame. Both keys are omitted when empty — treat a missing key as []; every frame has at least one of them.
  • more: true means a large burst is being delivered across several frames and the rest follows within the next second or so. Each frame is self-contained and correct on its own; more is a hint (e.g. to debounce a re-sort), not something you must handle.
  • Coalescing means final-state-only: if an item is worn and removed in the same moment, you get one changed row with its final location — intermediate states are never sent.
  • There is no replay. char.items.update only carries deltas from the moment you’re connected forward. On login or reconnect, request char.inventory and char.equipment as usual to get your baseline, then let pushes keep it current.

Recommended model: one oid-keyed map (oid -> {loc, item}) for everything on your character. changed upserts by item.oid; removed deletes by oid. Inventory panel = entries with loc == "inv"; equipment panel = entries with loc starting "worn:"; a bag’s contents = entries with loc == "in:<bag's oid>". Full detail, including an incremental adoption ladder (you don’t have to build the full merge on day one), is in docs/updates/2026-07-14-gmcp-char-items-update-client-spec.md.

char.equipment — request-only[edit]

Request (no arguments) → full snapshot:

{ "full": true,
  "equipment": [ { "slot": "wield", "item": { …object detail… } }, … ] }

item is exactly an object.info detail object. Empty equipment array when nothing is worn.

Slot keys are unique per body position (left and right are distinct, so keying your equipment map by slot never clobbers a pair):

light, finger_l, finger_r, neck_1, neck_2, body, head, face, legs,
feet, hands, arms, shield, about, waist, wrist_l, wrist_r, wield,
held, ear_l, ear_r, arm, aux, amulet

A slot the server can’t classify reports as "unknown" rather than being dropped.

2026-07-14: no more pushed deltas. char.equipment used to also push per-item "full": false deltas as you wore/removed things, coalesced during bulk wear/remove into one full: true snapshot. Those pushes are gone — equipment changes now arrive through char.items.update (above), which covers your whole person, not just worn items. char.equipment is request/reply only now: fetch it for your initial snapshot and whenever you want to resync your equipment map from scratch.

4.4 Skills[edit]

skills.all — static reference, request once per session[edit]

{ "skills": [
    { "name": "kick",    "slot": 63, "type": "fight",  "trainLevels": [] },
    { "name": "bandage", "slot": 12, "type": "druid",  "trainLevels": [15, 25] }
] }
  • One entry per skill, in slot order. slot is the durable identifier — join char.skills on it.
  • type is the school: weapon, fight, merchant, rogue, thief, druid, medical, bardic, gun, mage, runic, ranger, miscellaneous, or none.
  • trainLevels — character levels where extra training tiers unlock (the raw schedule, not adjusted for your character); empty for single-tier skills. Total tiers = trainLevels.length + 1.
  • Spells are not in this table — see §4.5.

char.skills — your skill standing[edit]

{ "skills": [
    { "slot": 63, "qualifies": true,  "known": 1 },
    { "slot": 12, "qualifies": false, "known": 2 }
] }

The union of the SKILLS and ALLSKILLS views. A slot appears iff you’ve learned it or currently qualify for it:

state qualifies known
qualify, haven’t learned true 0
learned and still qualify true > 0
learned, lost the prerequisites false > 0

known is the trained tier count, not a percentage.

char.skills.query — how you can qualify for a skill[edit]

Request either form:

char.skills.query 63
char.skills.query { "slot": 63 }

slot is the skill number from skills.all or char.skills. One reply per request:

{ "slot": 63, "name": "kick", "status": "ok",
  "paths": [
    { "level": 5, "trainLevels": [12, 25],
      "stats": { "str": 40, "min": 1, "dex": 45, "con": 30,
                 "per": 1, "spi": 1 },
      "skills": [ { "slot": 108, "name": "martial arts" } ] }
  ],
  "exclusions": [] }

Top-level fields, always present:

  • slot — echoes the request; -1 if the request itself was malformed ("bad_request", below).
  • name — the skill name; null when status is "unknown_skill" or "bad_request".
  • status — one of:
    • "ok" — at least one path follows.
    • "unknown_skill" — no skill exists at that slot.
    • "unavailable" — the skill exists, but no path is currently open to you.
    • "bad_request" — the request wasn’t a bare int or an object with an int slot.
  • paths — array of qualification paths; empty unless status is "ok".
  • exclusions — array of {slot, name} for skills mutually exclusive with this one. Always present. Currently always empty on this server; the shape is reserved for when that gate is turned on.

You only ever receive paths available to your own character — the same set QUERY FULL <skill> would show you in the text game.

Per-path fields:

  • level — minimum character level for this path.
  • trainLevels — levels where additional training tiers unlock for this path. Don’t confuse this with skills.all’s trainLevels: that one is a single character-independent schedule for the whole skill, while this one folds each path’s own minimum level into the schedule, so the same skill can report different trainLevels on different paths. Empty when the skill has only one tier.
  • stats — always all six, in a fixed order: str, min, dex, con, per, spi. A value of 1 means no requirement for that stat (the text view hides these; GMCP always sends the full shape and leaves hiding them up to you).
  • skills — prerequisite skills for this path, as {slot, name}. Empty array when the path has none. Cross-reference slot against your char.skills state to show which prerequisites you already have.

Immortal characters additionally receive, per path:

  • axioms — tech, magic, civ, nature, warcraft.
  • prestige — an integer.

Worked example: "ok" with a single, empty path (bandage, slot 42; captured on the wire from a live mortal character):

{"slot": 42, "name": "bandage", "status": "ok",
 "paths": [
   {"level": 0, "trainLevels": [],
    "stats": {"str": 1, "min": 1, "dex": 1, "con": 1, "per": 1, "spi": 1},
    "skills": []}
 ],
 "exclusions": []}

This is the everything-is-1 shape you will see a lot: no level requirement, no stat requirement, no skill prerequisite, one tier. The skill still comes back with status: "ok" and one path; a path with nothing in it is still a valid path.

Worked example: "unavailable" (twogun, slot 150; captured from a live mortal character with no open path to it):

{"slot": 150, "name": "twogun", "status": "unavailable",
 "paths": [], "exclusions": []}

Note that name is still populated. The skill exists; this character simply has no path open to it right now. Compare this to unknown_skill, where name is null because the slot does not correspond to a real skill at all.

Worked example: "ok", multiple paths, immortal viewer (expert parry, slot 103; captured from a live immortal character — three separate paths for the same skill, each carrying the imm-only axioms and prestige fields a mortal viewer never sees):

{"slot": 103, "name": "expert parry", "status": "ok",
 "paths": [
   {"level": 30, "trainLevels": [],
    "stats": {"str": 1, "min": 25, "dex": 80, "con": 1, "per": 20, "spi": 1},
    "skills": [{"slot": 101, "name": "parry"}, {"slot": 102, "name": "advanced parry"}],
    "axioms": {"tech": 3, "magic": 0, "civ": 4, "nature": 0, "warcraft": 0},
    "prestige": 0},
   {"level": 30, "trainLevels": [],
    "stats": {"str": 1, "min": 25, "dex": 80, "con": 1, "per": 20, "spi": 1},
    "skills": [{"slot": 101, "name": "parry"}, {"slot": 102, "name": "advanced parry"}, {"slot": 135, "name": "evasion"}],
    "axioms": {"tech": 0, "magic": 0, "civ": 0, "nature": 0, "warcraft": 0},
    "prestige": 0},
   {"level": 30, "trainLevels": [],
    "stats": {"str": 1, "min": 25, "dex": 80, "con": 1, "per": 20, "spi": 1},
    "skills": [{"slot": 101, "name": "parry"}, {"slot": 102, "name": "advanced parry"}],
    "axioms": {"tech": 4, "magic": 0, "civ": 3, "nature": 0, "warcraft": 0},
    "prestige": 0}
 ],
 "exclusions": []}

Worth noticing: the stats and level are identical across all three paths (this skill’s variation between paths is entirely in the imm-only fields and the prerequisite skills list); and the order these three arrived in carries no meaning — path order is not stable, so sort client-side if you want a fixed display order.

4.5 Magic: spells and words[edit]

spells.all — static reference; one request, four replies[edit]

Requesting spells.all sends four messages, one per school batch: spells.know, spells.create, spells.cause, and spells.noschool (wordless spells — includes affect-carriers and procs, so you can name any slot you ever see). Each has the same shape:

{ "spells": [
    { "name": "identify", "slot": 530, "mana": 10, "listed": true,
      "words": [1], "categories": ["utility"], "element": "none" },
    { "name": "immolation", "slot": 657, "mana": 52, "listed": true,
      "words": [5, 14, 20], "categories": ["dd"], "element": "fire" }
] }
  • slot is the durable identifier — join char.spells and char.spell.update on it.
  • mana is the base cost. listed = appears in ALLSPELLS.
  • words — word ids in chant order (0–3 of them; always empty in spells.noschool, never empty in the other three). Join against words.all.
  • categories — what the spell does: dd (direct damage), dot (damage over time), buff, debuff, heal, summon, utility, necromancy. A spell can carry several (drown is ["dd","dot"]); placeholder slots carry [].
  • element — the damage flavor for dd/dot spells: fire, ice, magic, death, or other. Always present; "none" for non-damage spells.

words.all — static reference[edit]

{ "words": [
    { "id": 1,  "name": "vid",  "meaning": "know", "circle": 1 },
    { "id": 14, "name": "agni", "meaning": "fire", "circle": 3 }
] }

All words, in id order. The stat/level requirements for learning a word are deliberately not exposed — char.words’s qualifies field is the verdict.

char.words — your word standing[edit]

{ "words": [
    { "id": 1, "learned": true,  "qualifies": true },
    { "id": 3, "learned": false, "qualifies": true },
    { "id": 8, "learned": true,  "qualifies": false }
] }

Union of the WORDS and ALLWORDS views; same three-state logic as char.skills. Non-chanters get { "words": [] }.

char.spells — your spellbook[edit]

{ "spells": [ { "slot": 507, "level": 12 }, { "slot": 530, "level": 3 } ] }

Every spell you’ve cast at least once. level is the displayed spell level from SPELLBOOK. Non-chanters get { "spells": [] }.

char.spell.update — pushed only[edit]

A single-spell delta with char.spells field semantics, pushed when a spell’s level changes through play — first cast ("level": 0), mastery gain, or spellbook study:

{ "slot": 507, "level": 13 }

Not requestable. Administrative bulk changes to a character’s spells do not push updates — if you have reason to think that happened, re-request char.spells.

4.6 Runes[edit]

runes.all — static reference[edit]

The full rune table, as RUNES FULL would show a master runecaster:

{ "runes": [
    { "id": 0, "name": "fehu", "aett": "Freyr", "cost": 12,
      "costMerkstave": 12, "merkstave": true,
      "description": "Restores movement over time. Merkstave: ..." },
    { "id": 7, "name": "wunjo", "aett": "Freyr", "cost": 24,
      "costMerkstave": 0, "merkstave": false,
      "description": "Fast mana regeneration." }
] }
  • id is fully durable (it never changes, even across reboots).
  • aett is Freyr, Heimdall, or Tyr — 8 runes each, in id order.
  • Costs are base values; if the caster has aett focus, the effective cost is one third — apply that client-side if you display costs.
  • merkstave = the rune can be cast reversed; costMerkstave is 0 when it can’t.

char.runes — your learned runes[edit]

{ "runes": [0, 2, 5, 11] }

Just the learned ids, in order. Non-runecasters get { "runes": [] }.

4.7 Era abilities[edit]

abilities.all — static reference[edit]

{ "abilities": [
    { "id": 0, "key": "ClearCasting", "name": "Clear Casting",
      "era": "ancient", "maxLevel": 3, "pk": false },
    { "id": 8, "key": "DeathtrapAvoidance", "name": "Deathtrap Avoidance",
      "era": "industrial", "maxLevel": 5, "pk": false }
] }
  • id is stable for your session; key is the durable identifier across reboots. Fetch abilities.all once per login and join char.abilities on id.
  • era is ancient, medieval, or industrial.
  • maxLevel is the training cap for that ability.
  • pk: true marks abilities that stop working while you carry pk damage (they still appear in char.abilities — it’s a use-time gate, not a listing gate).

char.abilities — your earned abilities[edit]

{ "abilities": [ { "id": 0, "level": 2 }, { "id": 7, "level": 3 } ] }

level runs 1..maxLevel. Characters who haven’t earned any era abilities get { "abilities": [] }.

4.8 Tradeskills[edit]

tradeskills.all — static reference[edit]

{ "tradeskills": [
    { "id": 0, "name": "smithing", "limited": true },
    { "id": 7, "name": "farming",  "limited": false }
] }
  • id is stable for your session; name is the durable identifier.
  • limited: true marks the tradeskills that share the capped skill pool; unlimited ones can all be raised freely.
  • Recipes are intentionally not available over GMCP.

char.tradeskills — your levels[edit]

{ "tradeskills": [
    { "id": 0,  "level": 42, "title": "hobbyist" },
    { "id": 11, "level": 1,  "title": "beginner" }
] }

Every tradeskill you can use, including level-0 rows — the same list the TRADESKILLS command prints. (Mortals don’t see enchanting unless they currently have access to it.) title is the proficiency bracket: beginner, dabbler, hobbyist, apprentice, journeyman, master, grand master.

4.9 Factions[edit]

char.factions — your standings[edit]

{ "factions": [
    { "vnum": 13306, "name": "Yama Temple", "value": 1000,
      "min": 0, "max": 2000, "status": "Indifferent" },
    { "vnum": 33500, "name": "Iceland Explorers", "value": 0,
      "min": 0, "max": 2999, "status": "Unknown" }
] }
  • One entry per faction you have a standing with (hidden factions are never sent). vnum is fully durable.
  • value is your current standing; min/max are that faction’s bounds — enough to draw a meter.
  • status is the faction’s own label for your current standing; "Unknown" when your value falls outside its labeled ranges (the FACTIONS command shows the same).
  • There is no factions.all; each entry already carries the static fields a client needs.

4.10 Moods[edit]

moods.all — static reference[edit]

{ "moods": [
    { "id": 0,  "name": "normal" },
    { "id": 27, "name": "cheerful" }
] }

Every mood, in id order — the same list the MOODS command prints. All moods are available to all players. id is stable for your session; name is the durable identifier.

char.moods — your mood settings[edit]

{ "combat": 27, "talk": 0, "walk": 0, "social": 0, "temporary": null }

The mood id set in each of the four categories; 0 is normal, the default. temporary is the one-shot mood override that the next mood-bearing action consumes — null when none is pending, which is nearly always. Join the ids against moods.all for names.

4.11 Groups[edit]

group.info — request-only[edit]

{ "groups": [
    { "name": "Merlin", "longName": "Merlin the Great", "level": 25,
      "position": "Standing", "rank": "L",
      "hp": 180, "maxHp": 250, "mana": 400, "maxMana": 500,
      "move": 120, "maxMove": 150, "agg": 0, "sameRoom": true }, …
] }

rank is a single character: L = leader, f = the character you follow, 2 = next-in-line leader, space = ordinary member. sameRoom is true when that member is in the same room as you — useful for graying out members you can’t currently assist.

4.12 Comm delivery and messages[edit]

Negotiated delivery of person-to-person, group/party, and public channel comm traffic. This is its own section, not part of Groups — "tell" (below) has nothing to do with grouping, it just shares the same negotiation and frame machinery as "gtell"/"ptell". "channel" (below) covers the normal public channels (chat, muse, info, auction, death/level announcements, and so on) and has a different frame shape from the other three — see its own subsection.

comm.delivery.set — client → server, no reply[edit]

Tell the server how you want each comm kind delivered. Send any time after GMCP negotiates; takes effect immediately.

comm.delivery.set {"gtell": "gmcp", "ptell": "both", "tell": "both"}

The payload is an object mapping kind name to mode string.

  • Kinds shipped so far: "gtell" (group tell), "ptell" (party tell), "tell" (person-to-person tell — covers the TELL, PAGE, REPLY, RETELL, and IMMREPLY commands, plus the board operator’s automatic tell; the frame is identical regardless of which command produced it, so don’t try to infer the command from the frame), and "channel" (every normal public channel — chat, muse, info, auction, death/level announcements, and so on, plus clan traffic. One key governs all of them — there is no per-channel negotiation; filter or mute a specific channel (or clan) client-side off the frame’s subType/clan, see below. Clan speech and clan socials ride this same key; there is no separate clan negotiation).
  • Modes: "text" (today’s behavior, no frame — the default for every kind), "both" (the text line still arrives, plus one comm.message frame in the same flush), "gmcp" (the frame arrives and the text line does not — use this only when your UI fully owns rendering that kind).
  • Kinds you omit keep their current mode. Unknown kind names and unrecognized mode strings are silently ignored (the server logs them as a client bug); your other kinds’ modes are left untouched.
  • Per connection, not persisted. A fresh descriptor starts every kind at "text". Re-send your preferences on every connect and reconnect; nothing survives a disconnect server-side.
  • No ack. There is no reply to correlate against; the setting is in effect by the time your next message could observe it.

comm.message — pushed only, per your negotiated mode[edit]

One frame per receiving character whose mode for the kind is "gmcp" or "both" — and only when the text line would also have been sent (every game-side gate: group/party membership, silent rooms, tell refusals, channel subscription/ignores/gates, etc. is already applied before a frame is considered). Covers "gtell", "ptell", "tell", and "channel" today; more kinds may come later. "tell" has one deliberate exception to the “no text, no frame” rule — see the AFK note below. "channel" frames have a different shape from the other three (no color, plus subType/act/extraInfo) — the table below covers "gtell"/"ptell"/"tell"; "channel"’s own field table and examples follow in its own subsection.

{ "kind": "gtell", "from": "Keldor", "text": "Test one.",
  "line": "|U24Keldor tells the group, 'Test one.|U24'|U6",
  "color": { "fg": 11, "bg": -1, "attrs": [] } }
field presence notes
kind always "gtell", "ptell", or "tell".
from received frames The speaker’s name as rendered for you — same visibility/disguise resolution as the text line, capitalized. An invisible speaker you can’t see through renders per kind, matching each kind’s own text-line convention exactly: for "gtell"/"ptell" it is parenthesized and capitalized, e.g. "(Someone)" for an unseen immortal or "(Somebody)" for an unseen mortal (channel_name()/channel_name_int() hand-capitalizes inside the parens). For "tell" there are no parens at all — the bare capitalized form, "Someone"/"Somebody" (capitalize_first(PERS(...))). "channel" frames render this differently again — lowercase, unlike either of the above — see the channel subsection below, this row does not describe it. Never a name you couldn’t already see in text. Omitted on your own outgoing echo — that is the reliable self-marker; don’t parse line for “You tell”.
to "tell" echo frames only The tell target’s name, rendered for you the same way from is rendered for a receiver. This is how you know which conversation a sent tell belongs to. Mutually exclusive with from — a frame never carries both; to never appears on "gtell"/"ptell" frames or on received "tell" frames.
text always The message body, no server-added color codes, no surrounding quotes. For "gtell"/"ptell" this is after the server’s capitalize/punctuate pass. For "tell" it is not — the body arrives exactly as typed, no capitalization or trailing period added; render it verbatim.
line always The exact text line, u-color codes included, trailing CRLF stripped. In "gmcp" mode this is the line you would otherwise have received as text — render or discard it as you like.
color always U24/\|U39/\|U14 codes select.

Two group members with different color settings get different color values for the same gtell — it reflects your config, not the speaker’s; use it to tint your Chat/UI rendering to match what the player configured in-game.

Worked examples (gtell/ptell captured live on features/gmcp_comm; tell captured live on features/gmcp_tell):

Third-party gtell, "both" mode:

{ "kind": "gtell", "from": "Keldor", "text": "Test one.",
  "line": "|U24Keldor tells the group, 'Test one.|U24'|U6",
  "color": { "fg": 11, "bg": -1, "attrs": [] } }

Your own echo (no from):

{ "kind": "gtell", "text": "My own echo.",
  "line": "|U24You tell the group, 'My own echo.|U24'|U6",
  "color": { "fg": 11, "bg": -1, "attrs": [] } }

Party tell, color resolved from ptell’s own slot (distinct from the gtell example above even for the same speaker/session):

{ "kind": "ptell", "from": "Keldor", "text": "Check.",
  "line": "|U39Keldor tells the party, 'Check.|U39'|U6",
  "color": { "fg": 3, "bg": -1, "attrs": [] } }

Received tell, raw lowercase/no-period body (text byte-identical to what was typed — no server capitalization, no trailing period added):

{ "kind": "tell", "from": "Keldor", "text": "step two lowercase body no period",
  "line": "|U14Keldor tells you, 'step two lowercase body no period|U14'|U6",
  "color": { "fg": 2, "bg": -1, "attrs": [] } }

Your own sent tell (echo — to, no from):

{ "kind": "tell", "to": "Keldor", "text": "step three echo check",
  "line": "|U14You tell Keldor, 'step three echo check|U14'|U6",
  "color": { "fg": 2, "bg": -1, "attrs": [] } }

AFK-hidden tell, frame arrives while the text line is suppressed (see the AFK note below):

{ "kind": "tell", "from": "Keldor", "text": "step five afk hidden both mode",
  "line": "|U14Keldor tells you, 'step five afk hidden both mode|U14'|U6",
  "color": { "fg": 2, "bg": -1, "attrs": [] } }

Integration notes:

  • Mode also governs your own echo. In "gmcp" mode your own sent gtell/ptell/tell produces no text line either — only the frame.
  • Frame and text share a flush in "both" mode; order between them is not guaranteed. If you need to correlate, match the color-stripped line against the adjacent scrollback line.
  • Keep your existing text classifier as fallback for kinds without frames yet (channels, says, and so on) — negotiation is per kind precisely so you can migrate one at a time.
  • AFK capture is unaffected by mode (gtell/ptell). A player in "gmcp" mode with GTELL TO AFK configured still accumulates gtells in their AFK log server-side, even though no text line reaches the descriptor live.
  • Tell’s AFK-hide exception. A player who is AFK with the hide-tells-while-AFK config on gets no text line for an incoming tell today, in any mode — that config hides scrollback clutter, it does not mean “not received.” In "gmcp" or "both" mode you may get a comm.message frame for a tell whose text the player chose to hide while AFK — render it; that is the point. This is the one place a "tell" frame arrives with no matching text line even in "both" mode; every other frame in this section still follows “text line sent ⇒ frame eligible.”
  • Don’t re-render text with your own sentence-casing. For "gtell"/"ptell" the server already capitalized and punctuated it, so it’s display-ready as-is. For "tell", there is nothing to strip or add — the body is raw on the wire by design; apply your own formatting if your UI wants any.

Full contract and rationale: docs/updates/2026-08-05-gmcp-comm-message-handover.md (gtell/ptell) and docs/updates/2026-08-05-gmcp-comm-tell-handover.md (tell).

comm.message — channel frames (kind: "channel")[edit]

One "channel" key in comm.delivery.set governs every normal public channel (chat, muse, info, auction, death/level announcements, and whatever else flows through the game’s channel system) — there is no per-channel negotiation. Filter or mute a specific channel client-side off the frame’s subType.

Clan traffic rides this same "channel" kind. Clan speech and clan socials both arrive as kind: "channel" with subType: "Clan" (the constant string, not a per-clan name) plus a clan member carrying the capitalized clan keyword exactly as the text tag shows it (e.g. "Gmcpone") — or "All" on an immortal’s copy of an all-clans broadcast. from, self, and act behave exactly as they do on every other channel: from is present on spoken messages and absent on code-generated clan announcements, self: true marks your own copy, act: true marks a clan social. There is no separate negotiation key for clan — the same "channel" mode you set governs it. Clan channels still never appear in the comm.channels catalog (below) — key your clan UI off the clan member on each frame, not off a catalog entry that will never exist.

Player-sent chat, as received by a subscriber in "both"/"gmcp" mode:

{ "kind": "channel", "subType": "Chat", "from": "Bob",
  "text": "anyone around?",
  "line": "<the line exactly as YOUR chan_format rendered it>" }

Code-generated announcement (info/auction/death/level — no speaker):

{ "kind": "channel", "subType": "Info",
  "text": "Welcome to the world, Rusalka!",
  "line": "..." }
field presence notes
kind always "channel".
subType always The channel’s name, verbatim as configured ("Chat", "Muse", "Info", "Auction", …) — channel names arrive capitalized, not lowercase; treat as an opaque identifier-plus-display-string, not something to parse further. Clan traffic uses the constant "Clan" regardless of which clan — split clan frames by the clan member below, not by subType.
clan Clan traffic only, else omitted The capitalized clan keyword exactly as the text tag shows it (e.g. "Gmcpone"), or "All" on an immortal’s copy of a broadcast sent to every clan at once (no single clan to name). Absent on every non-clan channel frame. This is the field to split clan tabs on — subType is always "Clan" and carries no per-clan information by itself.
from speaker frames only Present when a character spoke; rendered for you — per-viewer by the same identity pipeline as the text line. Unseen speakers render parenthesized with lowercase inside the parens, e.g. "(someone)" for an unseen immortal or "(somebody)" for an unseen mortal — this does NOT match gtell/ptell’s capitalized "(Someone)"/"(Somebody)", nor tell’s bare "Someone"/"Somebody" (see the from row in the shared table above); channel is its own third rendering path. Capitalization applies normally except where blocked by a leading paren, leaving letters immediately after an opening paren lowercase — that’s why unseen speakers show (someone). Matches the text line’s own rendering exactly (same bug, not a frame-only artifact); documented as-is, not fixed here. Absent on speakerless traffic (info, auction, death, level). This is how you tell an announcement from a speech message — check whether from is present, don’t infer it from subType. Not omitted on your own outgoing echo — unlike gtell/ptell/tell, channels have no “You” self-shape in the line, so your own sent chat still carries your own rendered from. Do NOT compare from to your own character name to detect your own message — a disguised speaker’s from is the disguised name on their own copy too, so a name comparison misfires exactly when it matters most. Use the self member below instead.
self true on your own copy, else omitted Present (and true) only on the frame delivered to the speaker’s own connection; every other viewer’s copy of the identical message omits the member entirely (never false). This is the only reliable self-detection signal for channel frames: from carries the same rendered name (disguised or not) on every copy including your own, so it cannot distinguish “I said this” from “someone who looks like me said this.” Absent on speakerless traffic (info, auction, death, level) — there is no speaker to be.
text always The message body: the raw text for a player send, the whole rendered line for an emote/social (see act below) — no server-added color codes, no per-viewer channel bracket, no chan_format decoration.
line always The full line exactly as rendered through your own chan_format — including a custom one you configured with CHANFORMAT. u-color codes included, trailing CRLF stripped. This is the case a client-side regex could never reliably parse; the frame is authoritative. Two subscribers with different chan_formats get different line values for the identical message — render per frame, don’t dedupe or cache by line.
act true, else omitted Present (and true) only when the message is emote/social-form (e.g. chat smile) — text/line carry the whole rendered act, with no Name: speaker-prefix shape. Omitted (not false) for ordinary speech.
extraInfo conditional, else omitted "channel_timeout" on an immortal’s copy of a message from a sender who is in channel timeout — mortal viewers of that sender get nothing at all (no frame, no text), and the sender’s own copy never carries it either. Omitted whenever there is nothing to say. Ignore any value you don’t recognize — this member is reserved for future markers and the set may grow without a client-version bump.
color never present Channels take their color from your own chan_format, not a fixed server u-slot — the codes already embedded in line are the styling. Don’t wait for a color member; parse line’s codes or style the message yourself.
to never present Channels are broadcast, not directed — there is no per-recipient target to name.

Worked examples (all captured live on features/gmcp_channel, .superpowers/sdd/gmcpchan-task-3-report.md — ground truth for the exact wire shapes below):

Third-party chat, receiver in "both" mode:

{ "kind": "channel", "subType": "Chat", "from": "Keldor",
  "text": "step2 both mode marker bravo",
  "line": "|U7[|U8Chat|U7]|U6 |U9Keldor|U7:|U6 |U10step2 both mode marker bravo|U6" }

Sender’s own echo — from is still present (contrast gtell/ptell/tell, where the sender’s own frame omits from entirely) and self: true marks it as your own copy; every other viewer’s frame for the same message has no self member at all:

{ "kind": "channel", "subType": "Chat", "from": "Keldor",
  "text": "step3 own echo marker charlie",
  "line": "|U7[|U8Chat|U7]|U6 |U9Keldor|U7:|U6 |U10step3 own echo marker charlie|U6",
  "self": true }

Disguised sender’s own echo — from is the DISGUISED name, not the real one, and self: true is still present. This is exactly the case self exists for: a disguised speaker’s from renders identically for every looker including themselves (name()/PERS() have no self-exception), so comparing from to your own character name would misclassify your own disguised message as someone else’s:

{ "kind": "channel", "subType": "Chat", "from": "An overworked milkmaid",
  "text": "self flag probe disguised",
  "line": "|U7[|U8Chat|U7]|U6 |U9An overworked milkmaid|U7:|U6 |U10self flag probe disguised|U6",
  "self": true }

The other viewer’s copy of the same disguised message carries the identical from value and no self member.

INFO announcement — no from member at all (not an empty string; the member is absent):

{ "kind": "channel", "subType": "Info",
  "text": "Please congratulate Keldor, the newest Hero of Legend!",
  "line": "|U7[|U8Info|U7]|U6 |U10Please congratulate Keldor, the newest Hero of Legend!|U6" }

Channel social/emote — act: true, text carries the whole rendered act, line has no Name: speaker-prefix shape:

{ "kind": "channel", "subType": "Chat", "from": "Keldor",
  "text": "Keldor smiles happily.",
  "line": "|U7[|U8Chat|U7]|U6 |U10Keldor smiles happily.|U6",
  "act": true }

Channel timeout, immortal viewer’s frame — extraInfo present, line carries no decoration (the immortal’s text line gets a (channel_timeout) prefix that the frame’s line never repeats):

{ "kind": "channel", "subType": "Chat", "from": "Keldor",
  "text": "step8 channel timeout marker",
  "line": "|U7[|U8Chat|U7]|U6 |U9Keldor|U7:|U6 |U10step8 channel timeout marker|U6",
  "extraInfo": "channel_timeout" }

A mortal viewer of the same sender gets no frame and no text at all for that message; the sender’s own copy carries no extraInfo.

Invisible immortal speaker — from is the parenthesized, lowercase someone-form; the real name never touches the wire:

{ "kind": "channel", "subType": "Chat", "from": "(someone)",
  "text": "step9 invis imm marker",
  "line": "|U7[|U8Chat|U7]|U6 |U9(someone)|U7:|U6 |U10step9 invis imm marker|U6" }

An unseen mortal speaker renders "(somebody)" in the identical position (same code path, is_mortal() branch — not independently wire-captured, but the flags and call site are identical to the immortal case above).

Clan traffic (captured live, .superpowers/sdd/gmcpclan-task-3-report.md — ground truth for the exact wire shapes below):

Plain clan speech, a same-clan third-party listener in "both" mode:

{ "kind": "channel", "subType": "Clan", "clan": "Gmcpone", "from": "Mandolin",
  "text": "p1 plain speech probe",
  "line": "|U7[|U8|U25Clan: Gmcpone|U7|U7]|U6 |U9Mandolin|U7:|U6 |U10p1 plain speech probe|U6" }

The speaker’s own copy of the same message — self: true, from still present (clan frames never omit from on your own echo, same as every other channel frame):

{ "kind": "channel", "subType": "Clan", "clan": "Gmcpone", "from": "Mandolin",
  "text": "p1 plain speech probe",
  "line": "|U7[|U8|U25Clan: Gmcpone|U7|U7]|U6 |U9Mandolin|U7:|U6 |U10p1 plain speech probe|U6",
  "self": true }

Clan social (clan smile) — dispatches as the social, act: true, text/line carry the whole rendered act with no Name: prefix shape, exactly like a public-channel social:

{ "kind": "channel", "subType": "Clan", "clan": "Gmcpone", "from": "Mandolin",
  "text": "Mandolin smiles happily.",
  "line": "|U7[|U8|U25Clan: Gmcpone|U7|U7]|U6 |U10Mandolin smiles happily.|U6",
  "act": true, "self": true }

All-clans broadcast, an immortal’s own copy — clan: "All" where a mortal viewer of the identical broadcast would see their own clan’s keyword instead (each mortal is keyed to their own clan, never "All"):

{ "kind": "channel", "subType": "Clan", "clan": "All", "from": "Rufus",
  "text": "p3 all clans broadcast probe",
  "line": "|U7[|U8|U25Clan: All|U7|U7]|U6 |U9Rufus|U7:|U6 |U10p3 all clans broadcast probe|U6",
  "self": true }

Code-generated clan announcement (no speaker) — no from, no self, same rule as an INFO/AUCTION announcement:

{ "kind": "channel", "subType": "Clan", "clan": "Gmcpone",
  "text": "The Gmcptwo Betas is now a friend of The Gmcpone Alphas.",
  "line": "|U7[|U8|U25Clan: Gmcpone|U7|U7]|U6 |U10The Gmcptwo Betas is now a friend of The Gmcpone Alphas.|U6" }

Integration notes:

  • Subscription still rules. Channel on/off, ignores, silent rooms, sleep, PK gates — a message you wouldn’t have received as text never frames either. Negotiation only changes the transport, not what you receive.
  • from’s presence, not subType, distinguishes an announcement from a spoken message. subType names the channel either way; only speech has a speaker.
  • No per-channel delivery keys. One "channel" negotiation governs chat, muse, info, auction, and every other channel; do your own per-channel muting client-side against subType.
  • Sent == framed, per viewer. Because line is rendered through each viewer’s own chan_format, the same underlying message produces different line bytes for different subscribers — never key a cache off line alone.
  • Detect your own message via self, never via from. from is the rendered, possibly disguised name on every copy, your own included — a disguised speaker’s own echo carries the disguised name in from too. self: true is the only member that is present on your own copy and absent on everyone else’s.
  • Clan is a subType, not a new negotiation. Clan frames arrive under the same "channel" mode you already negotiated — there is no "clan" key in comm.delivery.set. Recognize clan frames by subType === "Clan" and split them by the clan member; everything else (from, self, act, line rendering through the viewer’s own channel format) works exactly like a public channel.
  • Clan channels are never in the comm.channels catalog, even though clan traffic itself does flow through comm.message — see the catalog section below. Don’t gate clan-tab UI on a catalog entry that will never arrive.

Full contract and rationale: docs/updates/2026-08-05-gmcp-comm-channel-handover.md, docs/updates/2026-08-06-gmcp-ucode-slot-map-handover.md (self and the slots group), and docs/updates/2026-08-06-gmcp-clan-channel-handover.md (clan unification and the clan member).

comm.channels — request, login push, and change push[edit]

The catalog of public channel names — bare names only, no per-viewer state (no subscribed flag, no ownership, no welcome text, no flags). Request with an empty body; also pushed once at login/reconnect (alongside char.colors) and again, in full, any time the channel table changes.

{ "channels": ["Chat", "Info", "Auction", "Warzone", "Muse", "Event"] }
  • Names join comm.message’s subType byte-identically. Every entry is exactly the same capitalized string a "channel"-kind comm.message frame carries in subType (above) — key your channel-list UI on these names directly, no normalization needed.
  • Table order, not alphabetized. Treat order as insignificant; don’t rely on it for display sorting.
  • Full snapshot every time — replace, don’t diff. Every push (login, on request, or on change) is the complete current list. Adopt it wholesale each time; there is no delta form and none is planned.
  • Three send moments: login/character entry, on request (empty body, like the other snapshot packages), and on any table change (a channel created, deleted, renamed, or modified) — the change push goes to every GMCP-enabled connection, not just the one that triggered it.
  • Unknown-subType race window. The catalog and channel frames are two independent pushes, so a comm.message frame can name a channel you haven’t seen in a catalog snapshot yet (freshly created, catalog push still in flight) or one just removed (a frame sent just before a delete can arrive after the catalog already dropped it). Treat any subType as valid on arrival — render it even if it’s not currently in your catalog — and let the next catalog push reconcile your list. Don’t gate frame handling on catalog presence.
  • Clan channels are absent. The clan pseudo-channel never appears in this catalog and never will — it isn’t a row in the channel table ordinary channels come from. This is not the same as being out of scope: clan traffic itself does arrive over comm.message (kind: "channel", subType: "Clan", plus a clan member — see the clan examples above). Use that clan member, not a catalog lookup, to build clan-specific UI; treat subType: "Clan" as always valid on arrival even though "Clan" will never show up in a comm.channels snapshot.

Full contract: docs/updates/2026-08-06-gmcp-channel-catalog-handover.md.

4.13 Help[edit]

help.topic — request with {"keywords": "..."}[edit]

Fetches a helpfile — the same content the HELP command prints, without the banner.

help.topic {"keywords": "pk"}
{ "keywords": "pk", "found": true,
  "title": "PK PKILL PLAYERKILLING",
  "text": "Pkill, also known as Playerkilling, …" }
  • Keywords resolve exactly like HELP: case-insensitive, and aliases work (pk finds the pkill file). Omit keywords (or send {}) and you get the same summary a bare HELP shows, with "keywords": "summary" echoed back.
  • title is the topic’s keyword line, uppercased — use it as the window header. text is the raw helpfile body (plain text with newlines).
  • found: false (with empty title/text) means no helpfile you can read matches — nonexistent and restricted topics are indistinguishable by design.
  • The reply always has these four fields, so you can parse it with a fixed shape.

4.14 Journal[edit]

char.journal — request, also pushed[edit]

Your full quest journal — every entry you currently hold, complete and incomplete alike:

{ "entries": [
    { "vnum": 4108, "name": "The Lost Heirloom", "area": "midgaard",
      "complete": false, "stagesDone": 1, "stagesTotal": 3,
      "lastUpdated": 1752438000 },
    { "vnum": 4200, "name": "Trial by Fire", "area": "asgard",
      "complete": true, "stagesDone": 2, "stagesTotal": 2,
      "lastUpdated": 0 }
] }
  • One row per entry you hold, in catalog order. stagesDone equals stagesTotal once complete is true; otherwise it’s how many stages you’ve finished so far.
  • lastUpdated is a raw epoch-seconds timestamp — format it client-side. It can be 0 on an entry you haven’t touched this session: the timestamp isn’t saved to your player file, so it only starts counting once something about that entry changes while you’re online (added, a stage completes, it completes, or it’s removed).
  • An empty journal replies { "entries": [] }, same as any other per-character list package.
  • Pushed: the server re-sends this same full snapshot — not a delta — every time a journal entry is added, a stage completes, an entry completes, or an entry is removed. Treat every char.journal message (requested or pushed) as a full replace of your local list.

char.journal.entry — request with {"vnum": N}[edit]

Detail for one journal entry:

char.journal.entry {"vnum": 4108}
{ "vnum": 4108, "found": true, "name": "The Lost Heirloom",
  "area": "midgaard",
  "description": "An old family amulet has gone missing from the manor.",
  "complete": false,
  "stages": [
    { "id": 1, "text": "Find the amulet.", "done": true },
    { "id": 2, "text": "Return it to Lady Anne.", "done": false },
    { "id": 3, "text": "Report back to the mayor.", "done": false }
  ] }
  • stages always lists every stage’s text, whether done or not — a complete entry reports every stage done: true.
  • Never pushed — if you keep a detail pane open for an entry, re-fetch it after the next char.journal push.
  • Unknown, unheld, and malformed requests all get the same reply: { "vnum": N, "found": false } — nothing else. That covers a vnum that doesn’t exist, a real quest vnum you don’t currently hold, and a request with a missing or non-numeric vnum (which echoes back as 0). This is deliberate: the reply gives you no way to tell “no such quest” from “not your quest,” so char.journal.entry can’t be used to fish for quests in the game you haven’t found yet.
  • There’s no journals.all — LegendMUD doesn’t ship a static catalog of every quest in the game the way it does for skills or spells, since that would spoil quests you haven’t discovered. Fetch char.journal for what you hold, and char.journal.entry per vnum for detail; don’t wait for a bulk catalog package that isn’t coming.

4.15 Map panel[edit]

map.ansi.view — request, and pushed while subscribed[edit]

The MAP VIEW sketch as terminal text, sized for a panel of your own. This is not map data: it is the finished picture the game would print, escape sequences and all, for you to drop into an ANSI-aware widget. Build a real map from room.info (§4.2) instead; this is the game’s own drawing, for clients that would rather show that.

map.ansi.view {"width": 60, "height": 30}
{ "found": true, "width": 60, "height": 30,
  "text": "\u001b[0;36m[Naraka] \u001b[0;37mA Shrine to Yama\r\n …" }
{ "found": false, "width": 80, "height": 24,
  "reason": "You cannot see to draw anything." }
  • text is terminal-ready. Rows are joined with \r\n and there is no trailing one. It carries real ANSI escapes when the player has color on, and plain text when they don’t — the same output MAP VIEW sends to the screen, rendered at their own color setting. Render it in a fixed-width, ANSI-aware view; don’t parse it, and don’t strip the escapes and expect the sketch to still line up in color.
  • Size is optional and always clamped, never refused. Omit width/height (or send 0) and you get the player’s own screen, with an 80x24 fallback. Whatever you ask for is held to 22-511 columns and 4-100 rows, and the reply echoes the size you actually got — match your panel to that, not to what you asked for. A very large canvas does not draw a bigger map: the sketch has its own ceiling and simply centers in what you gave it.
  • found: false means there is no map for this player right now, and reason is the one line the game itself would show (no cartography skill, blind, and so on). Blank the panel and show the reason. Standing somewhere the game can’t lay out — inside an instance, say — is not a refusal: you get found: true and a one-line text saying so.

map.ansi.subscribe — opt in, then it follows you[edit]

map.ansi.subscribe {"enabled": true, "width": 60, "height": 30}
{ "enabled": true, "width": 60, "height": 30 }
  • Send it once per connection, after you know your panel size. A bare map.ansi.subscribe {} subscribes at your screen size; {"enabled": false} stops it (and the reply’s size fields come back 0).
  • Subscribing sends one map.ansi.view straight away, so the panel fills immediately instead of waiting for the player to move.
  • After that, one map.ansi.view arrives after every room.info push — that is, on every move, look, login and reconnect — at the size you subscribed with. Nothing else triggers it: the sketch only ever changes when the player moves.
  • Resizing your panel means subscribing again with the new size; the server remembers the size you gave it, not your terminal’s.
  • The subscription lives on the connection. A reconnect starts unsubscribed, so send it again as part of your session bootstrap.

4.16 Media: sound and music (Client.Media)[edit]

The server speaks the MUD Client Media Protocol (Mudlet, BeipMU and LociTerm implement it natively). It is off unless you ask: list "Client.Media 1" in core.supports.set (or add it with core.supports.add) and the server starts sending; a later set without it, or a remove, stops it. The server operator can also switch the whole feature off, in which case you get nothing whatever you declare.

The subscription comes in two halves you can take separately, so a client can offer a music switch and a sound-effects switch:

Entry You receive
"Client.Media 1" both halves (what the spec’s clients send)
"Client.Media.Music 1" background music from area files and the login-screen track ("type": "music")
"Client.Media.Sound 1" ambient sounds from area files, doors, locks and script sounds ("type": "sound")

Flip a half mid-game with core.supports.add / core.supports.remove of that entry: dropping music sends a client.media.stop with fadeaway for the track that was playing, taking it back sends the play for wherever you are standing, and the other half is untouched. Every stop the server sends carries a type, so a stop only ever matches the half it belongs to. A _media stop from a script with no type or key stops everything and reaches a client holding either half.

All four packages are server→client. Values are JSON numbers and booleans (the spec allows strings too, so a tolerant parser is wise):

Package Body
client.media.default { "url": "https://…/media/%22 } — the base directory, once per connection before the first play/load. Always ends in /. Resolve every name against it unless a message carries its own url.
client.media.load { "name", "url"? } — prefetch a file.
client.media.play { "name", "url"?, "type"?, "tag"?, "source"?, "key"?, "caption"?, "volume"?, "loops"?, "fadein"?, "fadeout"?, "start"?, "finish"?, "priority"?, "continue"? } — only the members that were set arrive; spec defaults apply to the rest (type sound, volume 50, loops 1, continue true). source is this server’s addition to the spec, see below.
client.media.stop { "name"?, "type"?, "tag"?, "key"?, "priority"?, "fadeaway"?, "fadeout"? } — stop what matches; {} stops everything.

Semantics you must honor for the game to sound right:

  • key: a new play with the same key but a different name halts the old one. Crossfade the handover: the old play fades out over its fadeout while the new one fades in over its fadein, and treat an absent value as 2000 ms (the server omits zero-valued members, so a builder who wants a hard cut sends a small value such as 50). Area music always uses "key": "area-music"; ambient sounds from area files carry their own keys and layer over it.
  • continue: with true, a play naming the track already playing under that key keeps it going instead of restarting, and applies the new volume (ramp it over a few hundred ms rather than stepping). The server uses exactly this to turn a sound up as you walk toward its source: same name, same key, higher volume, continue: true. The server never re-sends an unchanged track, but honor the flag anyway.
  • loops: -1 is forever; area music arrives with -1.
  • priority: a play halts lower-priority media while it runs.
  • source (not in the MCMP spec; this server adds it): whose sound it is, from where you stand, so two people in the same room get different values for the same event. self (you did it), group (a groupmate did), otherpc (another player), npc (a mob), ambient (the environment: every play from an area file, music included). Set on spells, doors, locks, skills and your own level-up; absent on script sounds, the login track, and every stop. A per-source volume or mute is the intended use: mute otherpc and npc stealth sounds while keeping your own, for instance. Whether a targeted spell was aimed at you is NOT carried; the engine does not know it reliably at the point the sound is sent.
  • tag: every play the engine builds carries one except a builder’s area track: default on area music drawn from the server’s default list (see below), ambient on every Sound: item from an area file, and on one-shots door, lock, eat, drink and quaff (someone in the room eating, drinking or quaffing a potion), level (your own level-up or era level, sent to you alone), xp (an experience award you were shown, sent to you alone), spell (a spell going off or fizzling where the caster stands), skill (a named skill landing or missing where its user stands), combat (one weapon noise per armed fighter per fight round), death (a death cry, heard in the victim’s room and the rooms one exit away), shoot (bow and gun shots, throws), tradeskill (reserved; no play carries it yet). Useful for a per-tag volume or mute; nothing else depends on it. Prefer the tag when present and fall back to the key rule without.
  • fadeaway on stop: fade over the smaller of the remaining track and fadeout, then stop.

Sources: builders trigger one-shot sounds and music from mob, room and object scripts (room-wide, everyone present with support gets the same message), the engine’s own one-shots for doors, locks, levelling, skills, shots and combat rounds (operator-editable lists on the server, so which file plays for a given event can change without notice, and one event may have several files it picks from), and area files declare background music and ambient sounds per room or per zone: on every room change you get only the difference, a play for what newly reaches you (or whose volume changed), and a stop with fadeaway for what no longer does (two seconds for music, one for a sound). Cache files by url + name; the names are path fragments and may contain subdirectories (weather/rain.mp3).

Fight music. The first time your character starts fighting or is attacked, the server sends client.media.stop { "key": "area-music", "fadeaway": true, "fadeout": 2000 } and a client.media.play with "key": "fight-music", "type": "music", "loops": -1, "tag": "fight", "fadein": 2000. It keeps playing while anyone in your room is fighting, including after you are rescued or knocked out, and five seconds after your room goes quiet or you leave it the server sends client.media.stop { "key": "fight-music", "fadeaway": true, "fadeout": 2000 } followed by the area or default track for your room as a normal play. Nobody who merely watches a fight gets it. The fight tag is in no category, so the category switches never drop it; use a per-tag volume if you want it quieter. Quitting or renting mid-fight sends the same stop with the other typed stops.

Login-screen music. If the operator has configured a track for it, the moment you list Client.Media on a fresh connection (before a character is in the game) you get the base url and a client.media.play with "key": "login-music", "type": "music", "loops": -1. It plays through the banner, the account menus and character creation. On the first room placement the server sends client.media.stop { "key": "login-music", "fadeaway": true, "fadeout": 2000 } and the area track, if any, follows in the same breath. Every trip back to the menus brings it back: when your character quits or rents, the server sends typed fadeaway stops for the area music and every ambient sound, then the login play again, and the placement stop follows on the next login. Declaring Client.Media only after the character is in a room skips it until the next trip to the menus.

Default music. Where no area file gives a room music, the server plays a track from a per-era default list instead, under the same "key": "area-music", with "tag": "default" so you can tell it from a builder’s track. One is picked at random when the player arrives and held through room and era changes until an area track takes over; leaving that area picks a fresh one. Three packages go with it, none with a reply: two client → server, one server → client:

Package Body
client.media.settings.set { "defaultmusic": false } turns default music off for this connection: a fadeaway stop for the current default and no more picks. true turns it back on and a track starts at once if nothing else reaches the room. The server does not remember it between connections; send it after core.supports.set on every connect. Area music, sounds, one-shots and the login track are untouched. Bad payloads are logged server-side and ignored. { "categories": { "combat": false, "shooting": false } } turns whole categories of one-shot off for this connection: the server never sends a play whose tag falls in a declined category (skills: skill; spells: spell; combat: combat, death; shooting: shoot; tradeskills: tradeskill; level: level, xp; other: door, lock, eat, drink, quaff and any play with no tag). The object replaces your whole choice each time: send only the names turned off, {} or no categories member is all on. Names are lower case and matched exactly. Unknown names and non-booleans are logged and skipped, the rest applied. Stops still arrive for anything that was playing. Not remembered between connections; send it after core.supports.set on every connect. Music, default and ambient are not categories; use your own switches for them.
client.media.categories server to client, once per connection right after your Client.Media declaration: ["skills","spells","combat","shooting","tradeskills","level","other"], the categories of one-shot the server will let you decline, in a fixed order. Render one switch per name you receive; a new category needs no client release.
client.media.next { "name": "<file>" } asks for a different default track; name is the file from the last default play and may be omitted. Works only while a default track is playing: enable the control after a play with "tag": "default", disable it on any play without that tag or a stop for area-music. The new track arrives as a normal client.media.play. When the era’s list has a single track nothing arrives; that is not an error.

5. What the server pushes[edit]

Everything else is request-only. These arrive on their own:

Package When
char.prompt every prompt — your main live-vitals feed
char.prompt.delta opt-in: changed prompt keys only on output-triggered renders
room.info every room change and LOOK
client.media.categories once per connection, right after your Client.Media declaration: the one-shot categories you may decline (§4.16)
char.items.update any object-state change on your person (get, drop, wear, remove, container moves); at most one coalesced frame per game moment
char.spell.update a spell’s level changes through play
char.journal (full snapshot) a journal entry is added, a stage completes, an entry completes, or an entry is removed
char.colors (full snapshot) once at login/reconnect, then whenever the player’s color config changes
comm.channels (full snapshot) once at login/reconnect, then whenever the channel table changes (created, deleted, renamed, modified)
comm.message a gtell/ptell/tell/channel message you would have received as text, if you negotiated "gmcp" or "both" for that kind (§4.12) — conditional on comm.delivery.set, unlike everything else in this table; "tell" also arrives while AFK-hidden even with no text line, see §4.12
map.ansi.view opt-in: after every room.info push, once you have sent map.ansi.subscribe (§4.15)
logging.error your request couldn’t be handled
client.media.* only after you list "Client.Media 1" in core.supports.set/.add (§4.16): the base url once, the login-screen track if one is configured, then plays/stops from scripts and on area changes

char.equipment no longer pushes — it’s request/reply only now (§4.3). Use char.items.update to keep your equipment and inventory current.

A practical session bootstrap:

  1. Negotiate GMCP (§1).
  2. Request the static tables you care about: skills.all, spells.all, words.all, runes.all, abilities.all, tradeskills.all, moods.all.
  3. Request your character’s state: char.score, char.status, char.skills, char.spells, char.words, char.runes, char.abilities, char.tradeskills, char.factions, char.moods, char.inventory, char.equipment, group.info, char.journal.
  4. Let the pushes keep prompt, room, inventory/equipment (via char.items.update), journal, colors, and the channel catalog current (char.colors and comm.channels both arrive on their own at login); re-request anything else when you want it fresh (e.g. group.info on a timer, char.factions after questing).
  5. Fetch helpfiles on demand with help.topic — no need to prefetch; entries resolve in one round trip. Fetch journal-entry detail on demand with char.journal.entry, per vnum, when a quest pane opens.
  6. If you show the game’s own map sketch, send map.ansi.subscribe with your panel’s size (§4.15) and re-send it whenever that panel is resized.

6. Accepted no-ops[edit]

core.hello, core.keepalive, core.ping, and external.discord.hello are accepted without error but do nothing. core.supports.set / .add / .remove are read for exactly one entry, Client.Media (§4.16), and otherwise ignored: listing or omitting any other package there does not change what the server broadcasts. A future subscription model may honor the rest.

7. Errors[edit]

Any malformed request, unknown package, or invalid JSON payload gets:

logging.error { "error": …, "package": "...", "message": "..." }

Common causes: a package name without a dot, a request body that isn’t valid JSON, oversized requests (§2), or a package name typo.