Difference between revisions of "GMCP"
| 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>), <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>"PKOK"</code>, <code>"PKE, PKOK"</code>, or <code>"none"</code>. <code>resistances</code> has one entry per | <code>"PKOK"</code>, <code>"PKE, PKOK"</code>, or <code>"none"</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>{ "conditions": ["hungry", …] | null }</code> | * <code>char.status.conditions</code> → <code>{ "conditions": ["hungry", …] | null }</code> | ||
| Line 189: | Line 694: | ||
"susceptibilities": [ "cold" ] | "susceptibilities": [ "cold" ] | ||
}</pre> | }</pre> | ||
| − | + | <ul> | |
| − | + | <li><p><code>disposition</code> — always one of <code>"beneficial"</code>, <code>"detrimental"</code>, or | |
| − | + | <code>"neutral"</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>"beneficial"</code> entries that sends | ||
| + | <code>removeaffect <name></code> (the entry’s <code>name</code> verbatim) — word the | ||
| + | player-facing confirmation <code>Remove <name>?</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>"sanctuary"</code>, <code>"detect_invis"</code>).</p></li> | ||
| + | <li><p><code>dmgMods</code> — object of damage-type → signed percent modifier, e.g. | ||
| + | <code>{ "slash": 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>{ "timers": [{ "name", "time" }, …] | 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 <= 0</code> or the values are hidden, the mud shows <code>??</code> uncolored — | If <code>max <= 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>"channel"</code> group''', alongside | ||
| + | <code>"condition"</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>"slots"</code> group''', a third top-level | ||
| + | member alongside <code>"condition"</code> and <code>"channel"</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>"0"</code> .. <code>"57"</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["7"]</code> .. <code>slots["10"]</code> are value-identical to | ||
| + | <code>channel.bracket</code>/<code>name</code>/<code>speaker</code>/<code>text</code>, and <code>slots["16"]</code> .. | ||
| + | <code>slots["20"]</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> | ||
| − | + | <ul> | |
| − | + | <li><p>'''Darkness''': if your character can’t see, <code>name</code> and <code>desc</code> are both | |
| − | + | <code>"It is too dark to see..."</code> and <code>exits</code> is omitted entirely.</p></li> | |
| − | + | <li><p>'''Blindness''': <code>exits</code> is omitted while blind, whatever the light.</p></li> | |
| − | + | <li><p><code>area</code> is present only when the room belongs to a known area.</p></li> | |
| − | + | <li><p><code>vnum</code> 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.</p></li> | ||
| + | <li><p><code>instance</code> is present only inside a live instance: | ||
| + | <code>{ "id": "62a86aa50d3c3c8c", "room": 13314, "name": "Maharaurava" }</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>"indoors"</code>, <code>"underwater"</code>, | ||
| + | <code>"aerial"</code>, <code>"water"</code>, <code>"outdoors"</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>"open"</code>, <code>"closed"</code>, or | ||
| + | <code>"locked"</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: { "dam", "damMax" }</code> — only when the item has a damage / repair ceiling. | * <code>condition: { "dam", "damMax" }</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>"glow"</code>, <code>"magic"</code>, <code>"invis"</code> (the item is invis but you can see it anyway — style it accordingly), <code>"no_repair"</code>, <code>"no_backstab"</code>, <code>"unique"</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>"glow"</code>, <code>"magic"</code>, <code>"invis"</code> (the item is invis but you can see it anyway — style it accordingly), <code>"no_repair"</code>, <code>"no_backstab"</code>, <code>"unique"</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>[{ "stat", "mod" }, …]</code>, plus <code>{ "stat": "dmgmod", "mod", "pct" }</code> entries for damage-modifier affects. | * <code>affects[]</code> — <code>[{ "stat", "mod" }, …]</code>, plus <code>{ "stat": "dmgmod", "mod", "pct" }</code> entries for damage-modifier affects. | ||
| − | * <code>props{}</code> — the object’s key/value | + | * <code>use{}</code> — only on an item that can be USEd for a spell: <code>{ "spell", "spellName", "recycleInterval", "recycleLeft", "wear", "selfOnly" }</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>{ "spell", "spellName", "spellLevel", "chance", "leftSeconds" }</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 693: | 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>"tell"</code> (below) has nothing to do with grouping, it just shares the | ||
| + | same negotiation and frame machinery as <code>"gtell"</code>/<code>"ptell"</code>. <code>"channel"</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 {"gtell": "gmcp", "ptell": "both", "tell": "both"}</pre> | ||
| + | The payload is an object mapping kind name to mode string. | ||
| + | |||
| + | * Kinds shipped so far: <code>"gtell"</code> (group tell), <code>"ptell"</code> (party tell), <code>"tell"</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>"channel"</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>"text"</code> (today’s behavior, no frame — the default for every kind), <code>"both"</code> (the text line still arrives, plus one <code>comm.message</code> frame in the same flush), <code>"gmcp"</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>"text"</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>"gmcp"</code> | ||
| + | or <code>"both"</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>"gtell"</code>, <code>"ptell"</code>, <code>"tell"</code>, | ||
| + | and <code>"channel"</code> today; more kinds may come later. '''<code>"tell"</code> has one | ||
| + | deliberate exception to the “no text, no frame” rule''' — see the AFK | ||
| + | note below. <code>"channel"</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>"gtell"</code>/<code>"ptell"</code>/<code>"tell"</code>; <code>"channel"</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>"gtell"</code>, <code>"ptell"</code>, or <code>"tell"</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>"gtell"</code>/<code>"ptell"</code> it is parenthesized '''and capitalized''', e.g. <code>"(Someone)"</code> for an unseen immortal or <code>"(Somebody)"</code> for an unseen mortal (<code>channel_name()</code>/<code>channel_name_int()</code> hand-capitalizes inside the parens). For <code>"tell"</code> there are no parens at all — the bare capitalized form, <code>"Someone"</code>/<code>"Somebody"</code> (<code>capitalize_first(PERS(...))</code>). '''<code>"channel"</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>"tell"</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>"gtell"</code>/<code>"ptell"</code> frames or on received <code>"tell"</code> frames. | ||
| + | |- | ||
| + | | <code>text</code> | ||
| + | | always | ||
| + | | The message body, no server-added color codes, no surrounding quotes. For <code>"gtell"</code>/<code>"ptell"</code> this is ''after'' the server’s capitalize/punctuate pass. '''For <code>"tell"</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>"gmcp"</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>"both"</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>"gmcp"</code> mode your own sent gtell/ptell/tell produces no text line either — only the frame. | ||
| + | * '''Frame and text share a flush in <code>"both"</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>"gmcp"</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>"gmcp"</code> or <code>"both"</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>"tell"</code> frame arrives with no matching text line even in <code>"both"</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>"gtell"</code>/<code>"ptell"</code> the server already capitalized and punctuated it, so it’s display-ready as-is. For <code>"tell"</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: "channel"</code>) ==== | ||
| + | |||
| + | One <code>"channel"</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>"channel"</code> kind.''' Clan speech and | ||
| + | clan socials both arrive as <code>kind: "channel"</code> with <code>subType: "Clan"</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>"Gmcpone"</code>) — or <code>"All"</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>"channel"</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>"both"</code>/<code>"gmcp"</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>"channel"</code>. | ||
| + | |- | ||
| + | | <code>subType</code> | ||
| + | | always | ||
| + | | The channel’s name, verbatim as configured (<code>"Chat"</code>, <code>"Muse"</code>, <code>"Info"</code>, <code>"Auction"</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>"Clan"</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>"Gmcpone"</code>), or <code>"All"</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>"Clan"</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>"(someone)"</code> for an unseen immortal or <code>"(somebody)"</code> for an unseen mortal — this does NOT match gtell/ptell’s capitalized <code>"(Someone)"</code>/<code>"(Somebody)"</code>, nor tell’s bare <code>"Someone"</code>/<code>"Somebody"</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>"channel_timeout"</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>"both"</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>"(somebody)"</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>"both"</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: "All"</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>"All"</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>"channel"</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>"channel"</code> mode you already negotiated — there is no <code>"clan"</code> key in <code>comm.delivery.set</code>. Recognize clan frames by <code>subType === "Clan"</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>"channel"</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: "channel"</code>, <code>subType: "Clan"</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: "Clan"</code> as always valid on arrival even though <code>"Clan"</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>{"keywords": "..."}</code> ==== | ==== <code>help.topic</code> — request with <code>{"keywords": "..."}</code> ==== | ||
| Line 709: | 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. | + | === 4.14 Journal === |
==== <code>char.journal</code> — request, also pushed ==== | ==== <code>char.journal</code> — request, also pushed ==== | ||
| Line 747: | Line 1,803: | ||
* '''Unknown, unheld, and malformed requests all get the same reply''': <code>{ "vnum": N, "found": 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>{ "vnum": N, "found": 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 {"width": 60, "height": 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 {"enabled": true, "width": 60, "height": 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>{"enabled": 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>"Client.Media 1"</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>"Client.Media 1"</code> | ||
| + | | both halves (what the spec’s clients send) | ||
| + | |- | ||
| + | | <code>"Client.Media.Music 1"</code> | ||
| + | | background music from area files and the login-screen track (<code>"type": "music"</code>) | ||
| + | |- | ||
| + | | <code>"Client.Media.Sound 1"</code> | ||
| + | | ambient sounds from area files, doors, locks and script sounds (<code>"type": "sound"</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>{ "url": "https://…/media/" }</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>{ "name", "url"? }</code> — prefetch a file. | ||
| + | |- | ||
| + | | <code>client.media.play</code> | ||
| + | | <code>{ "name", "url"?, "type"?, "tag"?, "source"?, "key"?, "caption"?, "volume"?, "loops"?, "fadein"?, "fadeout"?, "start"?, "finish"?, "priority"?, "continue"? }</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>{ "name"?, "type"?, "tag"?, "key"?, "priority"?, "fadeaway"?, "fadeout"? }</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>"key": "area-music"</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 { "key": "area-music", "fadeaway": true, "fadeout": 2000 }</code> and a <code>client.media.play</code> with | ||
| + | <code>"key": "fight-music"</code>, <code>"type": "music"</code>, <code>"loops": -1</code>, <code>"tag": "fight"</code>, <code>"fadein": 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 { "key": "fight-music", "fadeaway": true, "fadeout": 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>"key": "login-music"</code>, <code>"type": "music"</code>, <code>"loops": -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 { "key": "login-music", "fadeaway": true, "fadeout": 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>"key": "area-music"</code>, with <code>"tag": "default"</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>{ "defaultmusic": 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>{ "categories": { "combat": false, "shooting": 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>["skills","spells","combat","shooting","tradeskills","level","other"]</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>{ "name": "<file>" }</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>"tag": "default"</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 765: | 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 777: | 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>"gmcp"</code> or <code>"both"</code> for that kind (§4.12) — conditional on <code>comm.delivery.set</code>, unlike everything else in this table; <code>"tell"</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>"Client.Media 1"</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 790: | 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> | + | # 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. | + | <code>core.hello</code>, <code>core.keepalive</code>, <code>core.ping</code>, and <code>external.discord.hello</code> |
| − | <code>core. | + | are accepted without error but do nothing. <code>core.supports.set</code> / <code>.add</code> / |
| − | + | <code>.remove</code> are read for exactly one entry, <code>Client.Media</code> (§4.16), and | |
| − | ( | + | otherwise ignored: listing or omitting any other package there does not |
| − | what the server broadcasts. A future subscription model may | + | change what the server broadcasts. A future subscription model may honor |
| − | + | 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.Scoreworks), and must be dotted — at leastword.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.infoandchar.inventory(anoid),help.topic(keywords),char.journal.entry(vnum),char.skills.query(aslot),comm.delivery.set(a kind → mode object, §4.12), andmap.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.errorreply (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).
*.allpackages 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
*.allonce 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.spellsgets{"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.deltacontaining 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.promptonly: 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—nullabove level 50 (no further levels). Below 50 it is XP to the next level; at exactly 50, XP to redeem.xp.eraSplitPctand everyxp.era.*.exp—nullbelow level 50 for non-remorts (the text SCORE shows?there; the detail is hidden until you reach 50).carry.maxRent—nullabove 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 }—timeis remaining seconds; stacked copies of the same affect collapse into one entry. Each entry can also carryapplies,grants,dmgMods,resists,immunities, andsusceptibilities— see below. TheaffectedBymember embedded directly inchar.statusis 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,detrimentalwins. Exactly these three strings — but treat an unknown value asneutralrather than erroring, per the usual forward-compatibility rule. Beneficial affects are player-removable: a UI may offer a remove control on"beneficial"entries that sendsremoveaffect <name>(the entry’snameverbatim) — word the player-facing confirmationRemove <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. loweracis 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 withresists/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 fullshows 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 labelsSTATUSprints (e.g.,mod_buf_hitrollis 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, ornullwhen 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 }nameis the same keyword stringchar.groupmembers carry — use it to correlate the two packages.longNameis the display name.conditions,affectedBy, andtimershave exactly the shapes documented above for the player’s own slices (including the affect payload keys andnullwhen 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
slotsat 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.lineis the ground truth for exact rendering. Where acomm.messageframe carries its owncolormember, that member stays authoritative for its segment;slotsfills in everythingcolordoesn’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,
nameanddescare both"It is too dark to see..."andexitsis omitted entirely.Blindness:
exitsis omitted while blind, whatever the light.areais present only when the room belongs to a known area.vnumis 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.instanceis present only inside a live instance:{ "id": "62a86aa50d3c3c8c", "room": 13314, "name": "Maharaurava" }.idis an opaque string, unique to this opening and never reused.roomis the origin vnum in the source block the instance was copied from.nameis the builder’s name for the instance and is omitted when unset. Key a saved instance map onname, with rooms keyed onroom; when there is noname, map the instance for the session only and drop it whenidchanges 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_vnumis always present, slot vnums included. When the destination is a live instance room the exit also carriesto_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 realto_vnumand noto_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 liveexits. Absent field = static room.typeis always present: one of"indoors","underwater","aerial","water","outdoors".is_innis 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 seefalse.exitsmirrors the in-game autoexit line, including its secrecy rules — hidden or unrevealed exits simply don’t appear. Per exit:diralways;dooronly for door exits ("open","closed", or"locked");to_nameonly when the destination’s name is visible.echois 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, androom, the word for the room itself. Useroomwhen present, elsezone, else treat the room asnone. An absent member isnone. The words:word meaning nonedry, no processing; also what an unset level means small_indoorsa room, tavern, hut large_indoorsa big interior, warehouse, temple nave hallstone cathedral scale, long bright tail cavelong dark tail outdoor_smallalley, courtyard, forest clearing outdoor_largeopen field, plain, sea underwatermuffled, the one that filters the dry signal too The member rides every
room.infooutside 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.timeris the ticks until the item decays,-1for 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 throughchar.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 }—speedis the effective base speed factor. Weapons with an on-hit spell addspellChance,spell,spellLevel; weapons with a secondary attack addsecondaryAttack,secondaryMaxDam(andsecondarySpellChance/secondarySpell/secondarySpellLevelwhen 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" }.spellNameis the printable name;spellis the same number the weapon block uses.recycleIntervalis the cooldown in seconds andrecycleLeftthe seconds until the item is ready again (0 = usable now; count it down client-side or re-request).weartrue means it must be worn to use;selfOnlytrue 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" }.spellandspellNameas inuse{};chancethe percent chance a drink casts it;leftSecondsthe real seconds until the flavor wears off, accurate to one tick, count it down likerecycleLeft. The block disappears when the flavor expires, the container is emptied, or the character rents; each of those reaches a carried item throughchar.items.update, so drop it when the pushed item has noflavor. Fountains also still carry the rawspell2*slots in their per-type block; preferflavor{}.props{}— a whitelisted subset of the object’s key/value properties, present only when the server lists keys in itsGMCP_OBJECT_PROPSsetting and the object carries one of them. The default list is empty, so expect noprops{}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 completeobject.infodetail 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 aslook 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
}
itemis exactly anobject.infodetail object — the same onechar.inventoryrows andchar.equipmentitems use.locsays where the object now lives:"inv"(carried, unworn, top level),"worn:<slot>"(the same slot vocabulary aschar.equipment, below), or"in:<container-oid>"(direct container only — for nested bags, reconstruct the tree by following each object’s ownlocoid).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/removedper frame. Both keys are omitted when empty — treat a missing key as[]; every frame has at least one of them. more: truemeans 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;moreis 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
changedrow with its final location — intermediate states are never sent. - There is no replay.
char.items.updateonly carries deltas from the moment you’re connected forward. On login or reconnect, requestchar.inventoryandchar.equipmentas 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.
slotis the durable identifier — joinchar.skillson it. typeis the school:weapon,fight,merchant,rogue,thief,druid,medical,bardic,gun,mage,runic,ranger,miscellaneous, ornone.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;-1if the request itself was malformed ("bad_request", below).name— the skill name;nullwhenstatusis"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 intslot.
paths— array of qualification paths; empty unlessstatusis"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 withskills.all’strainLevels: 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 differenttrainLevelson 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 of1means 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-referenceslotagainst yourchar.skillsstate 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" }
] }
slotis the durable identifier — joinchar.spellsandchar.spell.updateon it.manais the base cost.listed= appears in ALLSPELLS.words— word ids in chant order (0–3 of them; always empty inspells.noschool, never empty in the other three). Join againstwords.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 fordd/dotspells:fire,ice,magic,death, orother. 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." }
] }
idis fully durable (it never changes, even across reboots).aettisFreyr,Heimdall, orTyr— 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;costMerkstaveis0when 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 }
] }
idis stable for your session;keyis the durable identifier across reboots. Fetchabilities.allonce per login and joinchar.abilitiesonid.eraisancient,medieval, orindustrial.maxLevelis the training cap for that ability.pk: truemarks abilities that stop working while you carry pk damage (they still appear inchar.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 }
] }
idis stable for your session;nameis the durable identifier.limited: truemarks 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).
vnumis fully durable. valueis your current standing;min/maxare that faction’s bounds — enough to draw a meter.statusis 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’ssubType/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 onecomm.messageframe 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-strippedlineagainst 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 withGTELL TO AFKconfigured 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 acomm.messageframe 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
textwith 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, notsubType, distinguishes an announcement from a spoken message.subTypenames 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 againstsubType. - Sent == framed, per viewer. Because
lineis rendered through each viewer’s ownchan_format, the same underlying message produces differentlinebytes for different subscribers — never key a cache offlinealone. - Detect your own message via
self, never viafrom.fromis the rendered, possibly disguised name on every copy, your own included — a disguised speaker’s own echo carries the disguised name infromtoo.self: trueis 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 incomm.delivery.set. Recognize clan frames bysubType === "Clan"and split them by theclanmember; everything else (from,self,act,linerendering through the viewer’s own channel format) works exactly like a public channel. - Clan channels are never in the
comm.channelscatalog, even though clan traffic itself does flow throughcomm.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’ssubTypebyte-identically. Every entry is exactly the same capitalized string a"channel"-kindcomm.messageframe carries insubType(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-
subTyperace window. The catalog and channel frames are two independent pushes, so acomm.messageframe 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 anysubTypeas 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 aclanmember — see the clan examples above). Use thatclanmember, not a catalog lookup, to build clan-specific UI; treatsubType: "Clan"as always valid on arrival even though"Clan"will never show up in acomm.channelssnapshot.
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 (pkfinds the pkill file). Omitkeywords(or send{}) and you get the same summary a bareHELPshows, with"keywords": "summary"echoed back. titleis the topic’s keyword line, uppercased — use it as the window header.textis the raw helpfile body (plain text with newlines).found: false(with emptytitle/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.
stagesDoneequalsstagesTotaloncecompleteistrue; otherwise it’s how many stages you’ve finished so far. lastUpdatedis a raw epoch-seconds timestamp — format it client-side. It can be0on 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.journalmessage (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 }
] }
stagesalways lists every stage’s text, whether done or not — acompleteentry reports every stagedone: true.- Never pushed — if you keep a detail pane open for an entry, re-fetch it after the next
char.journalpush. - 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-numericvnum(which echoes back as0). This is deliberate: the reply gives you no way to tell “no such quest” from “not your quest,” sochar.journal.entrycan’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. Fetchchar.journalfor what you hold, andchar.journal.entryper 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." }
textis terminal-ready. Rows are joined with\r\nand 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 send0) 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: falsemeans there is no map for this player right now, andreasonis 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 getfound: trueand a one-linetextsaying 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 back0). - Subscribing sends one
map.ansi.viewstraight away, so the panel fills immediately instead of waiting for the player to move. - After that, one
map.ansi.viewarrives after everyroom.infopush — 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 differentnamehalts the old one. Crossfade the handover: the old play fades out over itsfadeoutwhile the new one fades in over itsfadein, 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: withtrue, a play naming the track already playing under that key keeps it going instead of restarting, and applies the newvolume(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: samename, samekey, highervolume,continue: true. The server never re-sends an unchanged track, but honor the flag anyway.loops:-1is 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: muteotherpcandnpcstealth 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:defaulton area music drawn from the server’s default list (see below),ambienton every Sound: item from an area file, and on one-shotsdoor,lock,eat,drinkandquaff(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.fadeawayon stop: fade over the smaller of the remaining track andfadeout, 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:
- Negotiate GMCP (§1).
- Request the static tables you care about:
skills.all,spells.all,words.all,runes.all,abilities.all,tradeskills.all,moods.all. - 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. - Let the pushes keep
prompt,room,inventory/equipment(viachar.items.update),journal,colors, and the channelcatalogcurrent (char.colorsandcomm.channelsboth arrive on their own at login); re-request anything else when you want it fresh (e.g.group.infoon a timer,char.factionsafter questing). - Fetch helpfiles on demand with
help.topic— no need to prefetch; entries resolve in one round trip. Fetch journal-entry detail on demand withchar.journal.entry, per vnum, when a quest pane opens. - If you show the game’s own map sketch, send
map.ansi.subscribewith 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.