Difference between revisions of "GMCP Packages"

From LegendMUD
Jump to navigation Jump to search
 
(One intermediate revision by one other user not shown)
Line 1: Line 1:
The following packages are available via [[GMCP]]. Some packages may only be requested from the MUD, others may only be broadcast from the MUD, and others may be requested or broadcast.
 
  
== Char ==
+
This document describes LegendMUD’s GMCP (Generic MUD Communication
=== char.prompt ===
+
Protocol) support from the client’s point of view: how to enable it,
'''''Request and broadcast'''''
+
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.
  
The char.prompt message includes all values available in the prompt.
+
In-game, <code>HELP GMCP</code> covers the basics.
This list can be seen in the PROMPT CODES help file.
 
  
The char.prompt package does not require any parameters and can
+
-----
be sent an empty object as the request message.
 
  
Example: <code>{}</code>
+
== 1. Enabling GMCP ==
  
=== char.status ===
+
GMCP is telnet option '''201'''. On connect the server offers it:
'''''Request only'''''
 
  
The char.status message includes all values visible from the STATUS
+
<pre>server → client:  IAC WILL 201
command. This includes conditions, affects, and timers. If only one of
+
client → server:  IAC DO  201    (enables GMCP)
these sets of values is needed, consider using one of the char.status
+
client → server:  IAC DONT 201    (disables GMCP)</pre>
sub packages.
+
Most scriptable clients handle this negotiation for you and expose
 +
GMCP events directly. Nothing is sent over GMCP until you answer
 +
<code>IAC DO 201</code>.
  
The char.status package does not require any parameters and can
+
All GMCP traffic — both directions — is framed as telnet
be sent an empty object as the request message.
+
sub-negotiation:
  
Example: <code>{}</code>
+
<pre>IAC SB 201  &lt;package name&gt; [&lt;JSON payload&gt;]  IAC SE</pre>
 +
A single space separates the package name from the JSON when a payload
 +
is present.
  
=== char.status.conditions ===
+
== 2. Making requests ==
'''''Request only'''''
 
  
The char.status.conditions message includes all of the conditions
+
Send the package name, optionally followed by JSON arguments:
as seen in the STATUS command.
 
  
The char.status.conditions package does not require any parameters and can
+
<pre>char.score
be sent an empty object as the request message.
+
object.info {&quot;oid&quot;:&quot;0x1a2b3c4d5e6f7890&quot;}</pre>
 +
Rules:
  
Example: <code>{}</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.
 +
* Most packages take no arguments; any request body they receive is ignored. The exceptions are <code>object.info</code> and <code>char.inventory</code> (an <code>oid</code>), <code>help.topic</code> (<code>keywords</code>), and <code>char.journal.entry</code> (<code>vnum</code>).
 +
* 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.
  
=== char.status.affectedby ===
+
Every reply arrives as its own GMCP message, tagged with the package
'''''Request only'''''
+
name. One request can produce several messages (<code>spells.all</code> sends
 +
four).
  
The char.status.affectedby message includes all of the affects
+
== 3. Reading the reference: conventions ==
as seen in the STATUS command.
 
  
The char.status.affectedby package does not require any parameters and can
+
* '''Request-only''' — sent only when you ask. Most packages.
be sent an empty object as the request message.
+
* '''Pushed''' — sent by the server when game state changes, whether or not you asked (§5 lists them).
 +
* '''<code>*.all</code> packages are static reference data.''' They describe the game, not your character, and do not change during play. Request each once per session, cache it, and join the per-character packages against it by id.
 +
* '''Ids are stable for your session''' but not guaranteed across server reboots for every package (each entry below says which field is the durable identifier). The safe pattern is: fetch <code>*.all</code> once per login, join on id from there.
 +
* '''Empty means empty.''' Per-character list packages always reply, even when the answer is “none” — a non-chanter asking for <code>char.spells</code> gets <code>{&quot;spells&quot;: []}</code>, not silence. You can rely on a reply to every valid request.
 +
* All numbers are raw integers — no display formatting, no commas.
  
Example: <code>{}</code>
+
== 4. Package reference ==
  
=== char.status.timers ===
+
=== 4.1 Character basics ===
'''''Request only'''''
 
  
The char.status.timers message includes all of the timers
+
==== <code>char.prompt</code> — pushed on every prompt, also requestable ====
as seen in the STATUS command.
 
  
The char.status.timers package does not require any parameters and can
+
An object keyed by prompt tokens: hit points, mana, movement, position,
be sent an empty object as the request message.
+
room, gold, opponent condition, and everything else the text prompt can
 +
show. Immortal-only tokens are omitted for mortals.
  
Example: <code>{}</code>
+
By default the keys are the short prompt codes. Turn on the
 +
<code>gmcplongpromptkeys</code> config option in-game to get verbose,
 +
self-describing keys instead — recommended for new clients. See
 +
<code>HELP GMCPLONGPROMPT</code>.
  
== Room ==
+
==== <code>char.prompt.delta</code> — opt-in, pushed only ====
=== room.info ===
 
'''''Request and broadcast'''''
 
  
The room.info messsage includes the name and description of the character's
+
Off by default — nothing about <code>char.prompt</code> changes until you ask.
current room. If the character is in the dark or can otherwise not see the
+
Opt in with:
room name and description, the message will not contain the accurate name
 
and description. This message also includes the name of the area for the
 
current room.
 
  
The room.info package does not require any parameters and can be sent
+
<pre>char.prompt.delta { &quot;on&quot;: true }</pre>
an empty object as the request message.
+
Once opted in:
  
Example: <code>{}</code>
+
* '''Every prompt render''' — whether you typed something or game output re-rendered it — arrives as <code>char.prompt.delta</code> containing only the keys whose values changed since the last message you were sent. Do not expect a full after your own commands.
 +
* '''Nothing changed''' → nothing arrives at all, not even an empty <code>{}</code>. 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 <code>char.prompt</code> only: right after opting in (your starting baseline), when you request one, after SHOWPROMPT, or when the server must rebase you (reconnect, key-style toggle, immortal-status change, character change). If you want a full, ask for it.
  
== Group ==
+
Send <code>char.prompt.delta { &quot;on&quot;: false }</code> to go back to full-every-
=== group.info ===
+
render. The setting is per connection and does not persist — re-send
'''''Request only'''''
+
it every session. The first prompt after opting in is always a full
 +
<code>char.prompt</code>.
  
The group.info message includes the information as seen in the GROUP
+
'''Merging deltas''': keep a cumulative baseline — your last full
command. If the character is not in a group, it will not include any
+
<code>char.prompt</code>, updated by every <code>char.prompt.delta</code> since — and apply
information.
+
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.
 +
<code>f</code>/<code>fighting_name</code> when combat ends) arrives as JSON <code>null</code> rather
 +
than being omitted — treat <code>null</code> as an ordinary value, in both
 +
directions (<code>null</code> → value and value → <code>null</code> are both changes). Deltas use whichever key style (short or
 +
<code>gmcplongpromptkeys</code>) your connection is set to, same as <code>char.prompt</code>;
 +
toggling that setting mid-session forces the next message to be a full
 +
<code>char.prompt</code> under the new names.
  
The group.info package does not require any parameters and can be sent
+
'''Resync any time''': request <code>char.prompt</code> and you get a full package;
an empty object as the request message.
+
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.
  
Example: <code>{}</code>
+
Values in a delta are byte-identical to what the same key would carry
 +
in a full <code>char.prompt</code> — same types, same formatting, only the
 +
selection of keys differs. Immortal-only keys follow the same
 +
present-for-imms/absent-otherwise rule as fulls.
  
== Logging ==
+
==== <code>char.score</code> — request-only ====
=== logging.error ===
 
'''''Broadcast only'''''
 
  
The logging.error message includes information about the error that was
+
A single snapshot of the full score sheet, grouped:
encountered while processing the GMCP request or broadcast.
+
 
 +
<pre>{
 +
  "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 },
 +
  "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 } ]
 +
}</pre>
 +
Nullable fields — treat <code>null</code> as “not applicable”:
 +
 
 +
* <code>identity.clan</code> — no clan; <code>identity.area</code> — unknown area.
 +
* <code>xp.toLevel</code> — <code>null</code> above level 50 (no further levels). Below 50 it is XP to the next level; at exactly 50, XP to redeem.
 +
* <code>xp.eraSplitPct</code> and every <code>xp.era.*.exp</code> — <code>null</code> below level 50 for non-remorts (the text SCORE shows <code>?</code> there; the detail is hidden until you reach 50).
 +
* <code>carry.maxRent</code> — <code>null</code> above level 50 (unlimited).
 +
 
 +
<code>sex</code> is <code>&quot;Male&quot;</code>, <code>&quot;Female&quot;</code>, or <code>&quot;Other&quot;</code>. <code>pvp</code> is <code>&quot;PKE&quot;</code>,
 +
<code>&quot;PKOK&quot;</code>, <code>&quot;PKE, PKOK&quot;</code>, or <code>&quot;none&quot;</code>. <code>resistances</code> has one entry per
 +
nonzero damage modifier; empty array when you have none.
 +
 
 +
Conditions, affects, and timers are deliberately ''not'' here — see
 +
<code>char.status</code>.
 +
 
 +
==== <code>char.status</code> — request-only ====
 +
 
 +
<code>{ conditions, affectedBy, timers }</code>, each an array or <code>null</code>. Three
 +
sub-packages return one slice each, in the same shapes:
 +
 
 +
* <code>char.status.conditions</code> → <code>{ &quot;conditions&quot;: [&quot;hungry&quot;, …] | null }</code>
 +
* <code>char.status.affectedby</code> → <code>{ &quot;affectedBy&quot;: [{ &quot;time&quot;, &quot;name&quot;, … }, …] | null }</code> — <code>time</code> is remaining seconds; stacked copies of the same affect collapse into one entry. Each entry can also carry <code>applies</code>, <code>grants</code>, <code>dmgMods</code>, <code>resists</code>, <code>immunities</code>, and <code>susceptibilities</code> — see below. The <code>affectedBy</code> member embedded directly in <code>char.status</code> is built the same way and carries identical entries.
 +
 
 +
'''Affect payload''' — additive, no opt-in, no new package. <code>time</code> and
 +
<code>name</code> are unchanged; <code>disposition</code> is '''always present'''; every other
 +
new key is '''omitted when it would be empty''', so a
 +
<code>{ &quot;time&quot;, &quot;name&quot;, &quot;disposition&quot; }</code> entry is still normal and common
 +
(e.g. a flag-only or otherwise-inert affect). Example of a
 +
fully-loaded entry:
 +
 
 +
<pre>{
 +
  "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" ]
 +
}</pre>
 +
* <code>disposition</code> — always one of <code>&quot;beneficial&quot;</code>, <code>&quot;detrimental&quot;</code>, or <code>&quot;neutral&quot;</code>; how the game classifies the affect (the same classification that colors the affect’s name green/red in the text affects list). If an affect somehow carries both classifications, <code>detrimental</code> wins. Exactly these three strings — but treat an unknown value as <code>neutral</code> rather than erroring, per the usual forward-compatibility rule. Beneficial affects are player-removable: a UI may offer a remove control on <code>&quot;beneficial&quot;</code> entries that sends <code>removeaffect &lt;name&gt;</code> (the entry’s <code>name</code> verbatim) — word the player-facing confirmation <code>Remove &lt;name&gt;?</code>, not the command name. Don’t offer it on detrimental/neutral entries; the server refuses those.
 +
* <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).
 +
* <code>grants</code> — array of flag names this affect grants (e.g. <code>&quot;sanctuary&quot;</code>, <code>&quot;detect_invis&quot;</code>).
 +
* <code>dmgMods</code> — object of damage-type → signed percent modifier, e.g. <code>{ &quot;slash&quot;: 10 }</code> means +10% slash damage. Its damage-type names heavily overlap with <code>resists</code> / <code>immunities</code> / <code>susceptibilities</code> (<code>flame</code>, <code>poison</code>, <code>slash</code>, …) but the sets are '''not identical''' — each key’s vocabulary comes from its own server table. Don’t build one fixed shared list; treat each key’s names as its own open vocabulary and ignore unknowns.
 +
* <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.
 +
* 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.
 +
* 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.
 +
* <code>char.status.timers</code> → <code>{ &quot;timers&quot;: [{ &quot;name&quot;, &quot;time&quot; }, …] | null }</code> — skill/ability reuse timers.
 +
 
 +
==== <code>char.offer</code> — request-only ====
 +
 
 +
Mirrors the OFFER command: <code>{ &quot;items&quot;: [{ &quot;name&quot;, &quot;rent&quot;, &quot;count&quot; }, …] | null, &quot;totalRent&quot;, &quot;totalItems&quot; }</code>.
 +
 
 +
==== <code>char.colors</code> — request, login push, and config-change push ====
 +
 
 +
The player’s own condition colors (the mud’s <code>color</code> 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 (<code>color set</code> / <code>color scheme</code> /
 +
<code>color set default</code>). Every message is a full snapshot — adopt it
 +
wholesale, never merge. Always complete (scheme defaults fill slots the
 +
player never touched), never <code>{}</code>.
 +
 
 +
<pre>{ "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"] }
 +
} }</pre>
 +
<code>fg</code>/<code>bg</code> 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. <code>attrs</code> draws from <code>&quot;bold&quot;</code>,
 +
<code>&quot;dim&quot;</code>, <code>&quot;italic&quot;</code>, <code>&quot;underline&quot;</code>, <code>&quot;blink&quot;</code>, <code>&quot;reverse&quot;</code> — same
 +
semantics as the SGR attributes in output text (<code>bold</code> can appear
 +
alongside a 0–7 fg instead of a bright 8–15 fg; render both like the
 +
output pane would; reinterpreting <code>blink</code> 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):
 +
 
 +
<pre>cur &gt;= max            -&gt; full
 +
cur &lt;= max / 10      -&gt; critical
 +
cur &lt;= max / 4        -&gt; bad
 +
cur &lt;= max * 2 / 3    -&gt; medium
 +
otherwise            -&gt; low</pre>
 +
If <code>max &lt;= 0</code> or the values are hidden, the mud shows <code>??</code> uncolored —
 +
fall back to your untinted rendering rather than guessing a tier.
 +
Full contract: <code>docs/updates/2026-07-15-gmcp-char-colors-client-spec.md</code>.
 +
 
 +
=== 4.2 Room and world ===
 +
 
 +
==== <code>room.info</code> — pushed on movement and look, also requestable ====
 +
 
 +
<pre>{ "name": "...", "desc": "...", "area": "...", "vnum": 3001,
 +
  "type": "indoors", "is_inn": false,
 +
  "exits": [ { "dir": "north", "door": "closed",
 +
              "to_name": "A Quiet Lane", "to_vnum": 3005 }, … ] }</pre>
 +
* '''Darkness''': if your character can’t see, <code>name</code> and <code>desc</code> are both <code>&quot;It is too dark to see...&quot;</code> and <code>exits</code> is omitted entirely.
 +
* '''Blindness''': <code>exits</code> is omitted while blind, whatever the light.
 +
* <code>area</code> is present only when the room belongs to a known area.
 +
* <code>vnum</code> is present for every room ''except'' rooms inside transient instances, whose recycled vnums are meaningless and suppressed. The same applies to <code>to_vnum</code> on exits. Don’t key persistent mapper data on instance rooms.
 +
* <code>type</code> is always present: one of <code>&quot;indoors&quot;</code>, <code>&quot;underwater&quot;</code>, <code>&quot;aerial&quot;</code>, <code>&quot;water&quot;</code>, <code>&quot;outdoors&quot;</code>.
 +
* <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>.
 +
* <code>exits</code> mirrors the in-game autoexit line, including its secrecy rules — hidden or unrevealed exits simply don’t appear. Per exit: <code>dir</code> always; <code>door</code> only for door exits (<code>&quot;open&quot;</code>, <code>&quot;closed&quot;</code>, or <code>&quot;locked&quot;</code>); <code>to_name</code> only when the destination’s name is visible.
 +
 
 +
=== 4.3 Objects, inventory, equipment ===
 +
 
 +
Objects are identified by '''OID''' — an opaque 64-bit id rendered as a
 +
hex string with a mandatory <code>0x</code> prefix (e.g. <code>&quot;0x1a2b3c4d5e6f7890&quot;</code>).
 +
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 <code>{ &quot;oid&quot;: &quot;&lt;what you sent&gt;&quot;, &quot;found&quot;: false }</code>.
 +
 
 +
==== <code>object.info</code> — request with <code>{&quot;oid&quot;: &quot;...&quot;}</code> ====
 +
 
 +
Full detail for one object. The reply always echoes the requested
 +
<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>.
 +
* <code>condition: { &quot;dam&quot;, &quot;damMax&quot; }</code> — only when the item has a damage / repair ceiling.
 +
* <code>flags[]</code> — every item flag set on the object, present only when at least one is set. Names are the lowercase flag words: <code>&quot;glow&quot;</code>, <code>&quot;magic&quot;</code>, <code>&quot;invis&quot;</code> (the item is invis but you can see it anyway — style it accordingly), <code>&quot;no_repair&quot;</code>, <code>&quot;no_backstab&quot;</code>, <code>&quot;unique&quot;</code>, and so on. New flags appear automatically as the game adds them, so ignore names you don’t recognize.
 +
* <code>wearFlags[]</code> — 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:
 +
** <code>weapon: { attack, maxDam, quality, speed }</code> — <code>speed</code> is the effective base speed factor. Weapons with an on-hit spell add <code>spellChance</code>, <code>spell</code>, <code>spellLevel</code>; weapons with a secondary attack add <code>secondaryAttack</code>, <code>secondaryMaxDam</code> (and <code>secondarySpellChance/secondarySpell/secondarySpellLevel</code> when the secondary carries a spell). Plain weapons omit all of those.
 +
** <code>armor: { acApply, material }</code>
 +
** <code>container: { capacity, capWeight, capSize, flags, keyVal, weightReduction }</code>
 +
** <code>gun: { numDice, diceSize, charges, maxCharges, ammoType, jamChance, explodeChance, accuracy }</code>, <code>ammo: { ammoType, shots[, affect] }</code>
 +
** <code>light</code>, <code>drink</code>, <code>food</code>, <code>wand</code>, <code>staff</code>, <code>scroll</code>, <code>potion</code>, <code>pill</code>, <code>money</code>, <code>key</code>, <code>oil</code>, <code>powder</code>, <code>rune</code>, <code>spelltool</code>, <code>spellbook</code>, <code>instrument</code>, <code>fountain</code>, <code>furniture</code>, <code>boat</code>, <code>bundle</code>, <code>vehicle</code>, <code>currency</code>, <code>currencyPile</code>, <code>tool</code>, <code>fuel</code>, <code>manaStore</code>, <code>map</code>, <code>lock</code>, <code>board</code>, <code>note</code>, <code>seeds</code>, <code>bandage</code>, <code>medicament</code>, <code>medicalTool</code>, <code>dressing</code>, <code>portal</code>, <code>attack</code>, <code>grenade</code>, <code>corpse</code> (NPC corpses: <code>{ mobVnum, undead, skinVnum }</code>), 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.
 +
* <code>affects[]</code> — <code>[{ &quot;stat&quot;, &quot;mod&quot; }, …]</code>, plus <code>{ &quot;stat&quot;: &quot;dmgmod&quot;, &quot;mod&quot;, &quot;pct&quot; }</code> entries for damage-modifier affects.
 +
* <code>props{}</code> — the object’s key/value property table, when it has one.
 +
 
 +
==== <code>char.inventory</code> — request, two modes ====
 +
 
 +
* '''No arguments''' → your base inventory (held, unworn items): <code>{ &quot;items&quot;: [ { …object detail… }, …] }</code>. Each row is a '''complete <code>object.info</code> detail object''' — no per-item follow-up fetch needed.
 +
* '''<code>{&quot;oid&quot;: &quot;&lt;container&gt;&quot;}</code>''' → that container’s direct contents: <code>{ &quot;oid&quot;, &quot;items&quot;: [ … ] }</code>, same full-detail entry shape. A closed container answers <code>{ &quot;oid&quot;, &quot;closed&quot;: true, &quot;items&quot;: [] }</code> (same rule as <code>look in</code>). Not found / not visible → <code>{ &quot;oid&quot;, &quot;found&quot;: false }</code>.
 +
 
 +
Tell the two reply modes apart by the <code>oid</code> key: container replies
 +
have it, base-inventory replies don’t.
 +
 
 +
'''Paging''': replies carry at most 100 rows. A capped reply adds
 +
<code>&quot;more&quot;: true</code> and <code>&quot;offset&quot;: &lt;n&gt;</code>; re-send the same request with that
 +
<code>offset</code> added (e.g. <code>{&quot;oid&quot;: &quot;…&quot;, &quot;offset&quot;: 100}</code>) to get the next
 +
page, until a reply arrives without <code>more</code>. Most inventories fit in
 +
one page.
 +
 
 +
To walk someone’s whole carry tree, request base inventory, then
 +
request each container row’s <code>oid</code> as you (or the user) open it —
 +
one container level per request.
 +
 
 +
<code>char.inventory</code> is request/reply only — it never pushes. For live
 +
updates as your inventory changes during play, see <code>char.items.update</code>
 +
below.
 +
 
 +
==== <code>char.items.update</code> — pushed only, coalesced per moment ====
 +
 
 +
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. <code>wear all</code>,
 +
<code>remove all</code>, and death each arrive as one message with final state,
 +
not a hail of per-item deltas.
 +
 
 +
<pre>{ "changed": [
 +
    { "loc": "inv",                    "item": { …object detail… } },
 +
    { "loc": "worn:head",              "item": { … } },
 +
    { "loc": "in:0x1a2b3c4d5e6f7890",  "item": { … } }
 +
  ],
 +
  "removed": [ "0xaabbccddeeff0011" ],
 +
  "more": true
 +
}</pre>
 +
* <code>item</code> is exactly an <code>object.info</code> detail object — the same one <code>char.inventory</code> rows and <code>char.equipment</code> items use.
 +
* <code>loc</code> says where the object now lives: <code>&quot;inv&quot;</code> (carried, unworn, top level), <code>&quot;worn:&lt;slot&gt;&quot;</code> (the same slot vocabulary as <code>char.equipment</code>, below), or <code>&quot;in:&lt;container-oid&gt;&quot;</code> (direct container only — for nested bags, reconstruct the tree by following each object’s own <code>loc</code> oid).
 +
* <code>removed</code> — 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 <code>changed</code>/<code>removed</code> per frame. Both keys are '''omitted when empty''' — treat a missing key as <code>[]</code>; every frame has at least one of them.
 +
* '''<code>more: true</code>''' means a large burst is being delivered across several frames and the rest follows within the next second or so. Each frame is self-contained and correct on its own; <code>more</code> is a hint (e.g. to debounce a re-sort), not something you must handle.
 +
* '''Coalescing means final-state-only''': if an item is worn and removed in the same moment, you get one <code>changed</code> row with its final location — intermediate states are never sent.
 +
* There is '''no replay'''. <code>char.items.update</code> only carries deltas from the moment you’re connected forward. On login or reconnect, request <code>char.inventory</code> and <code>char.equipment</code> as usual to get your baseline, then let pushes keep it current.
 +
 
 +
Recommended model: one oid-keyed map (<code>oid -&gt; {loc, item}</code>) for
 +
everything on your character. <code>changed</code> upserts by <code>item.oid</code>;
 +
<code>removed</code> deletes by oid. Inventory panel = entries with
 +
<code>loc == &quot;inv&quot;</code>; equipment panel = entries with <code>loc</code> starting
 +
<code>&quot;worn:&quot;</code>; a bag’s contents = entries with <code>loc == &quot;in:&lt;bag's oid&gt;&quot;</code>.
 +
Full detail, including an incremental adoption ladder (you don’t have
 +
to build the full merge on day one), is in
 +
<code>docs/updates/2026-07-14-gmcp-char-items-update-client-spec.md</code>.
 +
 
 +
==== <code>char.equipment</code> — request-only ====
 +
 
 +
Request (no arguments) → '''full snapshot''':
 +
 
 +
<pre>{ "full": true,
 +
  "equipment": [ { "slot": "wield", "item": { …object detail… } }, … ] }</pre>
 +
<code>item</code> is exactly an <code>object.info</code> detail object. Empty <code>equipment</code>
 +
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):
 +
 
 +
<pre>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</pre>
 +
A slot the server can’t classify reports as <code>&quot;unknown&quot;</code> rather than
 +
being dropped.
 +
 
 +
'''2026-07-14: no more pushed deltas.''' <code>char.equipment</code> used to also
 +
push per-item <code>&quot;full&quot;: false</code> deltas as you wore/removed things,
 +
coalesced during bulk wear/remove into one <code>full: true</code> snapshot.
 +
Those pushes are gone — equipment changes now arrive through
 +
<code>char.items.update</code> (above), which covers your whole person, not just
 +
worn items. <code>char.equipment</code> 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 ===
 +
 
 +
==== <code>skills.all</code> — static reference, request once per session ====
 +
 
 +
<pre>{ "skills": [
 +
    { "name": "kick",    "slot": 63, "type": "fight",  "trainLevels": [] },
 +
    { "name": "bandage", "slot": 12, "type": "druid",  "trainLevels": [15, 25] }
 +
] }</pre>
 +
* One entry per skill, in slot order. <code>slot</code> is the durable identifier — join <code>char.skills</code> on it.
 +
* <code>type</code> is the school: <code>weapon</code>, <code>fight</code>, <code>merchant</code>, <code>rogue</code>, <code>thief</code>, <code>druid</code>, <code>medical</code>, <code>bardic</code>, <code>gun</code>, <code>mage</code>, <code>runic</code>, <code>ranger</code>, <code>miscellaneous</code>, or <code>none</code>.
 +
* <code>trainLevels</code> — character levels where extra training tiers unlock (the raw schedule, not adjusted for your character); empty for single-tier skills. Total tiers = <code>trainLevels.length + 1</code>.
 +
* Spells are not in this table — see §4.5.
 +
 
 +
==== <code>char.skills</code> — your skill standing ====
 +
 
 +
<pre>{ "skills": [
 +
    { "slot": 63, "qualifies": true,  "known": 1 },
 +
    { "slot": 12, "qualifies": false, "known": 2 }
 +
] }</pre>
 +
The union of the SKILLS and ALLSKILLS views. A slot appears iff you’ve
 +
learned it or currently qualify for it:
 +
 
 +
{| class="wikitable"
 +
|-
 +
! state
 +
! <code>qualifies</code>
 +
! <code>known</code>
 +
|-
 +
| qualify, haven’t learned
 +
| <code>true</code>
 +
| <code>0</code>
 +
|-
 +
| learned and still qualify
 +
| <code>true</code>
 +
| <code>&gt; 0</code>
 +
|-
 +
| learned, lost the prerequisites
 +
| <code>false</code>
 +
| <code>&gt; 0</code>
 +
|}
 +
 
 +
<code>known</code> is the trained tier count, '''not''' a percentage.
 +
 
 +
=== 4.5 Magic: spells and words ===
 +
 
 +
==== <code>spells.all</code> — static reference; one request, four replies ====
 +
 
 +
Requesting <code>spells.all</code> sends four messages, one per school batch:
 +
<code>spells.know</code>, <code>spells.create</code>, <code>spells.cause</code>, and <code>spells.noschool</code>
 +
(wordless spells — includes affect-carriers and procs, so you can name
 +
any slot you ever see). Each has the same shape:
 +
 
 +
<pre>{ "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" }
 +
] }</pre>
 +
* <code>slot</code> is the durable identifier — join <code>char.spells</code> and <code>char.spell.update</code> on it.
 +
* <code>mana</code> is the base cost. <code>listed</code> = appears in ALLSPELLS.
 +
* <code>words</code> — word ids in chant order (0–3 of them; always empty in <code>spells.noschool</code>, never empty in the other three). Join against <code>words.all</code>.
 +
* <code>categories</code> — what the spell does: <code>dd</code> (direct damage), <code>dot</code> (damage over time), <code>buff</code>, <code>debuff</code>, <code>heal</code>, <code>summon</code>, <code>utility</code>, <code>necromancy</code>. A spell can carry several (drown is <code>[&quot;dd&quot;,&quot;dot&quot;]</code>); placeholder slots carry <code>[]</code>.
 +
* <code>element</code> — the damage flavor for <code>dd</code>/<code>dot</code> spells: <code>fire</code>, <code>ice</code>, <code>magic</code>, <code>death</code>, or <code>other</code>. Always present; <code>&quot;none&quot;</code> for non-damage spells.
 +
 
 +
==== <code>words.all</code> — static reference ====
 +
 
 +
<pre>{ "words": [
 +
    { "id": 1,  "name": "vid",  "meaning": "know", "circle": 1 },
 +
    { "id": 14, "name": "agni", "meaning": "fire", "circle": 3 }
 +
] }</pre>
 +
All words, in id order. The stat/level requirements for learning a
 +
word are deliberately not exposed — <code>char.words</code>’s <code>qualifies</code> field
 +
is the verdict.
 +
 
 +
==== <code>char.words</code> — your word standing ====
 +
 
 +
<pre>{ "words": [
 +
    { "id": 1, "learned": true,  "qualifies": true },
 +
    { "id": 3, "learned": false, "qualifies": true },
 +
    { "id": 8, "learned": true,  "qualifies": false }
 +
] }</pre>
 +
Union of the WORDS and ALLWORDS views; same three-state logic as
 +
<code>char.skills</code>. Non-chanters get <code>{ &quot;words&quot;: [] }</code>.
 +
 
 +
==== <code>char.spells</code> — your spellbook ====
 +
 
 +
<pre>{ "spells": [ { "slot": 507, "level": 12 }, { "slot": 530, "level": 3 } ] }</pre>
 +
Every spell you’ve cast at least once. <code>level</code> is the displayed spell
 +
level from SPELLBOOK. Non-chanters get <code>{ &quot;spells&quot;: [] }</code>.
 +
 
 +
==== <code>char.spell.update</code> — pushed only ====
 +
 
 +
A single-spell delta with <code>char.spells</code> field semantics, pushed when a
 +
spell’s level changes through play — first cast (<code>&quot;level&quot;: 0</code>),
 +
mastery gain, or spellbook study:
 +
 
 +
<pre>{ "slot": 507, "level": 13 }</pre>
 +
Not requestable. Administrative bulk changes to a character’s spells
 +
do '''not''' push updates — if you have reason to think that happened,
 +
re-request <code>char.spells</code>.
 +
 
 +
=== 4.6 Runes ===
 +
 
 +
==== <code>runes.all</code> — static reference ====
 +
 
 +
The full rune table, as RUNES FULL would show a master runecaster:
 +
 
 +
<pre>{ "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." }
 +
] }</pre>
 +
* <code>id</code> is fully durable (it never changes, even across reboots).
 +
* <code>aett</code> is <code>Freyr</code>, <code>Heimdall</code>, or <code>Tyr</code> — 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.
 +
* <code>merkstave</code> = the rune can be cast reversed; <code>costMerkstave</code> is <code>0</code> when it can’t.
 +
 
 +
==== <code>char.runes</code> — your learned runes ====
 +
 
 +
<pre>{ "runes": [0, 2, 5, 11] }</pre>
 +
Just the learned ids, in order. Non-runecasters get <code>{ &quot;runes&quot;: [] }</code>.
 +
 
 +
=== 4.7 Era abilities ===
 +
 
 +
==== <code>abilities.all</code> — static reference ====
 +
 
 +
<pre>{ "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 }
 +
] }</pre>
 +
* <code>id</code> is stable for your session; <code>key</code> is the durable identifier across reboots. Fetch <code>abilities.all</code> once per login and join <code>char.abilities</code> on <code>id</code>.
 +
* <code>era</code> is <code>ancient</code>, <code>medieval</code>, or <code>industrial</code>.
 +
* <code>maxLevel</code> is the training cap for that ability.
 +
* <code>pk: true</code> marks abilities that stop working while you carry pk damage (they still appear in <code>char.abilities</code> — it’s a use-time gate, not a listing gate).
 +
 
 +
==== <code>char.abilities</code> — your earned abilities ====
 +
 
 +
<pre>{ "abilities": [ { "id": 0, "level": 2 }, { "id": 7, "level": 3 } ] }</pre>
 +
<code>level</code> runs 1..<code>maxLevel</code>. Characters who haven’t earned any era
 +
abilities get <code>{ &quot;abilities&quot;: [] }</code>.
 +
 
 +
=== 4.8 Tradeskills ===
 +
 
 +
==== <code>tradeskills.all</code> — static reference ====
 +
 
 +
<pre>{ "tradeskills": [
 +
    { "id": 0, "name": "smithing", "limited": true },
 +
    { "id": 7, "name": "farming",  "limited": false }
 +
] }</pre>
 +
* <code>id</code> is stable for your session; <code>name</code> is the durable identifier.
 +
* <code>limited: true</code> marks the tradeskills that share the capped skill pool; unlimited ones can all be raised freely.
 +
* Recipes are intentionally not available over GMCP.
 +
 
 +
==== <code>char.tradeskills</code> — your levels ====
 +
 
 +
<pre>{ "tradeskills": [
 +
    { "id": 0,  "level": 42, "title": "hobbyist" },
 +
    { "id": 11, "level": 1,  "title": "beginner" }
 +
] }</pre>
 +
Every tradeskill you can use, including level-0 rows — the same list
 +
the TRADESKILLS command prints. (Mortals don’t see <code>enchanting</code> unless
 +
they currently have access to it.) <code>title</code> is the proficiency bracket:
 +
<code>beginner</code>, <code>dabbler</code>, <code>hobbyist</code>, <code>apprentice</code>, <code>journeyman</code>,
 +
<code>master</code>, <code>grand master</code>.
 +
 
 +
=== 4.9 Factions ===
 +
 
 +
==== <code>char.factions</code> — your standings ====
 +
 
 +
<pre>{ "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" }
 +
] }</pre>
 +
* One entry per faction you have a standing with (hidden factions are never sent). <code>vnum</code> is fully durable.
 +
* <code>value</code> is your current standing; <code>min</code>/<code>max</code> are that faction’s bounds — enough to draw a meter.
 +
* <code>status</code> is the faction’s own label for your current standing; <code>&quot;Unknown&quot;</code> when your value falls outside its labeled ranges (the FACTIONS command shows the same).
 +
* There is no <code>factions.all</code>; each entry already carries the static fields a client needs.
 +
 
 +
=== 4.10 Moods ===
 +
 
 +
==== <code>moods.all</code> — static reference ====
 +
 
 +
<pre>{ "moods": [
 +
    { "id": 0,  "name": "normal" },
 +
    { "id": 27, "name": "cheerful" }
 +
] }</pre>
 +
Every mood, in id order — the same list the MOODS command prints. All
 +
moods are available to all players. <code>id</code> is stable for your session;
 +
<code>name</code> is the durable identifier.
 +
 
 +
==== <code>char.moods</code> — your mood settings ====
 +
 
 +
<pre>{ "combat": 27, "talk": 0, "walk": 0, "social": 0, "temporary": null }</pre>
 +
The mood id set in each of the four categories; <code>0</code> is <code>normal</code>, the
 +
default. <code>temporary</code> is the one-shot mood override that the next
 +
mood-bearing action consumes — <code>null</code> when none is pending, which is
 +
nearly always. Join the ids against <code>moods.all</code> for names.
 +
 
 +
=== 4.11 Groups ===
 +
 
 +
==== <code>group.info</code> — request-only ====
 +
 
 +
<pre>{ "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 }, …
 +
] }</pre>
 +
<code>rank</code> is a single character: <code>L</code> = leader, <code>f</code> = the character you
 +
follow, <code>2</code> = next-in-line leader, space = ordinary member.
 +
<code>sameRoom</code> is true when that member is in the same room as you —
 +
useful for graying out members you can’t currently assist.
 +
 
 +
=== 4.12 Help ===
 +
 
 +
==== <code>help.topic</code> — request with <code>{&quot;keywords&quot;: &quot;...&quot;}</code> ====
 +
 
 +
Fetches a helpfile — the same content the <code>HELP</code> command prints,
 +
without the banner.
 +
 
 +
<pre>help.topic {&quot;keywords&quot;: &quot;pk&quot;}</pre>
 +
<pre>{ "keywords": "pk", "found": true,
 +
  "title": "PK PKILL PLAYERKILLING",
 +
  "text": "Pkill, also known as Playerkilling, …" }</pre>
 +
* Keywords resolve exactly like <code>HELP</code>: case-insensitive, and aliases work (<code>pk</code> finds the pkill file). Omit <code>keywords</code> (or send <code>{}</code>) and you get the same summary a bare <code>HELP</code> shows, with <code>&quot;keywords&quot;: &quot;summary&quot;</code> echoed back.
 +
* <code>title</code> is the topic’s keyword line, uppercased — use it as the window header. <code>text</code> is the raw helpfile body (plain text with newlines).
 +
* <code>found: false</code> (with empty <code>title</code>/<code>text</code>) 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.13 Journal ===
 +
 
 +
==== <code>char.journal</code> — request, also pushed ====
 +
 
 +
Your full quest journal — every entry you currently hold, complete and
 +
incomplete alike:
 +
 
 +
<pre>{ "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 }
 +
] }</pre>
 +
* One row per entry you hold, in catalog order. <code>stagesDone</code> equals <code>stagesTotal</code> once <code>complete</code> is <code>true</code>; otherwise it’s how many stages you’ve finished so far.
 +
* <code>lastUpdated</code> is a raw epoch-seconds timestamp — format it client-side. '''It can be <code>0</code>''' on an entry you haven’t touched this session: the timestamp isn’t saved to your player file, so it only starts counting once something about that entry changes while you’re online (added, a stage completes, it completes, or it’s removed).
 +
* An empty journal replies <code>{ &quot;entries&quot;: [] }</code>, 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 <code>char.journal</code> message (requested or pushed) as a full replace of your local list.
 +
 
 +
==== <code>char.journal.entry</code> — request with <code>{&quot;vnum&quot;: N}</code> ====
 +
 
 +
Detail for one journal entry:
 +
 
 +
<pre>char.journal.entry {&quot;vnum&quot;: 4108}</pre>
 +
<pre>{ "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 }
 +
  ] }</pre>
 +
* <code>stages</code> always lists every stage’s text, whether done or not — a <code>complete</code> entry reports every stage <code>done: true</code>.
 +
* Never pushed — if you keep a detail pane open for an entry, re-fetch it after the next <code>char.journal</code> push.
 +
* '''Unknown, unheld, and malformed requests all get the same reply''': <code>{ &quot;vnum&quot;: N, &quot;found&quot;: false }</code> — nothing else. That covers a vnum that doesn’t exist, a real quest vnum you don’t currently hold, and a request with a missing or non-numeric <code>vnum</code> (which echoes back as <code>0</code>). This is deliberate: the reply gives you no way to tell “no such quest” from “not your quest,” so <code>char.journal.entry</code> can’t be used to fish for quests in the game you haven’t found yet.
 +
* There’s no <code>journals.all</code> — LegendMUD doesn’t ship a static catalog of every quest in the game the way it does for skills or spells, since that would spoil quests you haven’t discovered. Fetch <code>char.journal</code> for what you hold, and <code>char.journal.entry</code> per vnum for detail; don’t wait for a bulk catalog package that isn’t coming.
 +
 
 +
== 5. What the server pushes ==
 +
 
 +
Everything else is request-only. These arrive on their own:
 +
 
 +
{| class="wikitable"
 +
|-
 +
! Package
 +
! When
 +
|-
 +
| <code>char.prompt</code>
 +
| every prompt — your main live-vitals feed
 +
|-
 +
| <code>char.prompt.delta</code>
 +
| opt-in: changed prompt keys only on output-triggered renders
 +
|-
 +
| <code>room.info</code>
 +
| every room change and LOOK
 +
|-
 +
| <code>char.items.update</code>
 +
| any object-state change on your person (get, drop, wear, remove, container moves); at most one coalesced frame per game moment
 +
|-
 +
| <code>char.spell.update</code>
 +
| a spell’s level changes through play
 +
|-
 +
| <code>char.journal</code> (full snapshot)
 +
| a journal entry is added, a stage completes, an entry completes, or an entry is removed
 +
|-
 +
| <code>char.colors</code> (full snapshot)
 +
| once at login/reconnect, then whenever the player’s color config changes
 +
|-
 +
| <code>logging.error</code>
 +
| your request couldn’t be handled
 +
|}
 +
 
 +
<code>char.equipment</code> no longer pushes — it’s request/reply only now (§4.3).
 +
Use <code>char.items.update</code> to keep your equipment and inventory current.
 +
 
 +
A practical session bootstrap:
 +
 
 +
# Negotiate GMCP (§1).
 +
# 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>.
 +
# Let the pushes keep <code>prompt</code>, <code>room</code>, <code>inventory</code>/<code>equipment</code> (via <code>char.items.update</code>), <code>journal</code>, and <code>colors</code> current (<code>char.colors</code> arrives on its own at login); re-request anything else when you want it fresh (e.g. <code>group.info</code> on a timer, <code>char.factions</code> after questing).
 +
# 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.
 +
 
 +
== 6. Accepted no-ops ==
 +
 
 +
<code>core.hello</code>, <code>core.supports.set</code> / <code>.add</code> / <code>.remove</code>,
 +
<code>core.keepalive</code>, <code>core.ping</code>, and <code>external.discord.hello</code> are
 +
accepted without error but currently do nothing. You may send them
 +
(many client GMCP stacks do automatically); today they don’t change
 +
what the server broadcasts. A future subscription model may start
 +
honoring <code>core.supports.*</code>.
 +
 
 +
== 7. Errors ==
 +
 
 +
Any malformed request, unknown package, or invalid JSON payload gets:
 +
 
 +
<pre>logging.error { "error": …, "package": "...", "message": "..." }</pre>
 +
Common causes: a package name without a dot, a request body that isn’t
 +
valid JSON, oversized requests (§2), or a package name typo.

Latest revision as of 11:25, 18 July 2026

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

In-game, HELP GMCP covers the basics.


1. Enabling GMCP[edit]

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

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

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

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

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

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

2. Making requests[edit]

Send the package name, optionally followed by JSON arguments:

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

Rules:

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

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

3. Reading the reference: conventions[edit]

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

4. Package reference[edit]

4.1 Character basics[edit]

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

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

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

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

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

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

Once opted in:

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

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

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

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

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

char.score — request-only[edit]

A single snapshot of the full score sheet, grouped:

{
  "identity": {
    "name": "Merlin", "pretitle": "the Great", "title": "Mighty Wizard",
    "posttitle": "of the Tower", "sex": "Male", "age": 156,
    "played": 5000000, "hometown": "Midgaard", "clan": "Archmages",
    "level": 25, "align": 450, "practices": 15, "redemptions": 0,
    "pvp": "PKE, PKOK", "area": "The Midgaard Forest"
  },
  "vitals":  { "hp": 180, "maxHp": 250, "mana": 400, "maxMana": 500,
               "move": 120, "maxMove": 150 },
  "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.toLevelnull above level 50 (no further levels). Below 50 it is XP to the next level; at exactly 50, XP to redeem.
  • xp.eraSplitPct and every xp.era.*.expnull below level 50 for non-remorts (the text SCORE shows ? there; the detail is hidden until you reach 50).
  • carry.maxRentnull above level 50 (unlimited).

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

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

char.status — request-only[edit]

{ conditions, affectedBy, timers }, each an array or null. Three sub-packages return one slice each, in the same shapes:

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

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

{
  "time": 512,
  "name": "armor of faith",
  "disposition": "beneficial",
  "applies":          { "ac": -20, "saving_spell": -2 },
  "grants":           [ "sanctuary" ],
  "dmgMods":          { "slash": 10 },
  "resists":          [ "fire" ],
  "immunities":       [ "poison" ],
  "susceptibilities": [ "cold" ]
}
  • disposition — always one of "beneficial", "detrimental", or "neutral"; how the game classifies the affect (the same classification that colors the affect’s name green/red in the text affects list). If an affect somehow carries both classifications, detrimental wins. Exactly these three strings — but treat an unknown value as neutral rather than erroring, per the usual forward-compatibility rule. Beneficial affects are player-removable: a UI may offer a remove control on "beneficial" entries that sends removeaffect <name> (the entry’s name verbatim) — word the player-facing confirmation Remove <name>?, not the command name. Don’t offer it on detrimental/neutral entries; the server refuses those.
  • applies — object, numeric stat modifiers this affect currently contributes. Keys are stat names (strength, saving_spell, hp_regen, ac, …); values are signed JSON ints (negative values are normal and mean the same thing they do everywhere else — e.g. lower ac is better).
  • grants — array of flag names this affect grants (e.g. "sanctuary", "detect_invis").
  • dmgMods — object of damage-type → signed percent modifier, e.g. { "slash": 10 } means +10% slash damage. Its damage-type names heavily overlap with resists / immunities / susceptibilities (flame, poison, slash, …) but the sets are not identical — each key’s vocabulary comes from its own server table. Don’t build one fixed shared list; treat each key’s names as its own open vocabulary and ignore unknowns.
  • resists / immunities / susceptibilities — arrays of damage-type names this affect resists, grants immunity to, or makes the character more susceptible to.
  • All key/value vocabularies are mechanical lowercase of the game’s internal names — the same information STATUS full shows a player for that affect, just structured instead of prose. Key names are the server’s mechanical internal names, which sometimes differ from the friendly labels STATUS prints (e.g., mod_buf_hitroll is the HITROLL line). This is forward-compatible: new names can appear with no protocol change, so ignore any key or value you don’t recognize rather than treating it as an error.
  • If an affect contributes to the same stat/damage-type more than once (e.g. two stacked sources folded into one entry), the value you receive is already the summed total — you never need to add entries together yourself.
  • char.status.timers{ "timers": [{ "name", "time" }, …] | null } — skill/ability reuse timers.

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. Full contract: docs/updates/2026-07-15-gmcp-char-colors-client-spec.md.

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,
  "exits": [ { "dir": "north", "door": "closed",
               "to_name": "A Quiet Lane", "to_vnum": 3005 }, … ] }
  • Darkness: if your character can’t see, name and desc are both "It is too dark to see..." and exits is omitted entirely.
  • Blindness: exits is omitted while blind, whatever the light.
  • area is present only when the room belongs to a known area.
  • vnum is present for every room except rooms inside transient instances, whose recycled vnums are meaningless and suppressed. The same applies to to_vnum on exits. Don’t key persistent mapper data on instance rooms.
  • type is always present: one of "indoors", "underwater", "aerial", "water", "outdoors".
  • is_inn is always present and is character-dependent: whether you could rent here right now (false while fighting, for example). It is not a fixed property of the room. Immortals always see false.
  • exits mirrors the in-game autoexit line, including its secrecy rules — hidden or unrevealed exits simply don’t appear. Per exit: dir always; door only for door exits ("open", "closed", or "locked"); to_name only when the destination’s name is visible.

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.
  • condition: { "dam", "damMax" } — only when the item has a damage / repair ceiling.
  • flags[] — every item flag set on the object, present only when at least one is set. Names are the lowercase flag words: "glow", "magic", "invis" (the item is invis but you can see it anyway — style it accordingly), "no_repair", "no_backstab", "unique", and so on. New flags appear automatically as the game adds them, so ignore names you don’t recognize.
  • wearFlags[] — wear-position names.
  • A per-type block keyed by the item type, with named fields for that type. Nearly every item type in the game has one; the ones you’ll see most:
    • weapon: { attack, maxDam, quality, speed }speed is the effective base speed factor. Weapons with an on-hit spell add spellChance, spell, spellLevel; weapons with a secondary attack add secondaryAttack, secondaryMaxDam (and secondarySpellChance/secondarySpell/secondarySpellLevel when the secondary carries a spell). Plain weapons omit all of those.
    • armor: { acApply, material }
    • container: { capacity, capWeight, capSize, flags, keyVal, weightReduction }
    • gun: { numDice, diceSize, charges, maxCharges, ammoType, jamChance, explodeChance, accuracy }, ammo: { ammoType, shots[, affect] }
    • light, drink, food, wand, staff, scroll, potion, pill, money, key, oil, powder, rune, spelltool, spellbook, instrument, fountain, furniture, boat, bundle, vehicle, currency, currencyPile, tool, fuel, manaStore, map, lock, board, note, seeds, bandage, medicament, medicalTool, dressing, portal, attack, grenade, corpse (NPC corpses: { mobVnum, undead, skinVnum }), and a few more — every field is a named integer (or string where noted), so render what you receive.
    • Item types without an interpreted block (decorative/misc items, and a few whose values are internal state) simply omit it.
  • affects[][{ "stat", "mod" }, …], plus { "stat": "dmgmod", "mod", "pct" } entries for damage-modifier affects.
  • props{} — the object’s key/value property table, when it has one.

char.inventory — request, two modes[edit]

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

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

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

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

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

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

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

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

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

char.equipment — request-only[edit]

Request (no arguments) → full snapshot:

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

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

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

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

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

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

4.4 Skills[edit]

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

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

char.skills — your skill standing[edit]

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

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

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

known is the trained tier count, not a percentage.

4.5 Magic: spells and words[edit]

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

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

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

words.all — static reference[edit]

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

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

char.words — your word standing[edit]

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

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

char.spells — your spellbook[edit]

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

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

char.spell.update — pushed only[edit]

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

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

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

4.6 Runes[edit]

runes.all — static reference[edit]

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

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

char.runes — your learned runes[edit]

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

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

4.7 Era abilities[edit]

abilities.all — static reference[edit]

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

char.abilities — your earned abilities[edit]

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

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

4.8 Tradeskills[edit]

tradeskills.all — static reference[edit]

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

char.tradeskills — your levels[edit]

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

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

4.9 Factions[edit]

char.factions — your standings[edit]

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

4.10 Moods[edit]

moods.all — static reference[edit]

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

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

char.moods — your mood settings[edit]

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

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

4.11 Groups[edit]

group.info — request-only[edit]

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

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

4.12 Help[edit]

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

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

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

4.13 Journal[edit]

char.journal — request, also pushed[edit]

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

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

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

Detail for one journal entry:

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

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
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
logging.error your request couldn’t be handled

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

A practical session bootstrap:

  1. Negotiate GMCP (§1).
  2. Request the static tables you care about: skills.all, spells.all, words.all, runes.all, abilities.all, tradeskills.all, moods.all.
  3. Request your character’s state: char.score, char.status, char.skills, char.spells, char.words, char.runes, char.abilities, char.tradeskills, char.factions, char.moods, char.inventory, char.equipment, group.info, char.journal.
  4. Let the pushes keep prompt, room, inventory/equipment (via char.items.update), journal, and colors current (char.colors arrives on its own at login); re-request anything else when you want it fresh (e.g. group.info on a timer, char.factions after questing).
  5. Fetch helpfiles on demand with help.topic — no need to prefetch; entries resolve in one round trip. Fetch journal-entry detail on demand with char.journal.entry, per vnum, when a quest pane opens.

6. Accepted no-ops[edit]

core.hello, core.supports.set / .add / .remove, core.keepalive, core.ping, and external.discord.hello are accepted without error but currently do nothing. You may send them (many client GMCP stacks do automatically); today they don’t change what the server broadcasts. A future subscription model may start honoring core.supports.*.

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.