Difference between revisions of "GMCP Packages"
| (2 intermediate revisions by one other user not shown) | |||
| Line 1: | Line 1: | ||
| − | |||
| − | + | 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, <code>HELP GMCP</code> covers the basics. | |
| − | |||
| − | + | ----- | |
| − | |||
| − | + | == 1. Enabling GMCP == | |
| − | + | GMCP is telnet option '''201'''. On connect the server offers it: | |
| − | ''' | ||
| − | + | <pre>server → client: IAC WILL 201 | |
| − | + | client → server: IAC DO 201 (enables GMCP) | |
| − | + | client → server: IAC DONT 201 (disables GMCP)</pre> | |
| + | 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>. | ||
| − | + | All GMCP traffic — both directions — is framed as telnet | |
| − | + | sub-negotiation: | |
| − | + | <pre>IAC SB 201 <package name> [<JSON payload>] IAC SE</pre> | |
| − | + | A single space separates the package name from the JSON when a payload | |
| + | is present. | ||
| − | + | == 2. Making requests == | |
| − | |||
| − | == | ||
| − | |||
| − | + | Send the package name, optionally followed by JSON arguments: | |
| − | |||
| − | |||
| − | |||
| − | + | <pre>char.score | |
| − | + | object.info {"oid":"0x1a2b3c4d5e6f7890"}</pre> | |
| + | Rules: | ||
| − | + | * '''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. | ||
| − | + | Every reply arrives as its own GMCP message, tagged with the package | |
| − | + | name. One request can produce several messages (<code>spells.all</code> sends | |
| + | four). | ||
| − | + | == 3. Reading the reference: conventions == | |
| − | |||
| − | + | * '''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). | |
| + | * '''<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>{"spells": []}</code>, 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 == | |
| − | === | + | === 4.1 Character basics === |
| − | |||
| − | + | ==== <code>char.prompt</code> — pushed on every prompt, also requestable ==== | |
| − | |||
| − | + | 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 | |
| + | <code>gmcplongpromptkeys</code> config option in-game to get verbose, | ||
| + | self-describing keys instead — recommended for new clients. See | ||
| + | <code>HELP GMCPLONGPROMPT</code>. | ||
| − | === char. | + | ==== <code>char.prompt.delta</code> — opt-in, pushed only ==== |
| − | |||
| − | + | Off by default — nothing about <code>char.prompt</code> changes until you ask. | |
| − | + | Opt in with: | |
| − | + | <pre>char.prompt.delta { "on": true }</pre> | |
| − | + | Once opted in: | |
| − | + | * '''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. | ||
| − | + | Send <code>char.prompt.delta { "on": false }</code> 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 | |
| + | <code>char.prompt</code>. | ||
| − | + | '''Merging deltas''': keep a cumulative baseline — your last full | |
| − | + | <code>char.prompt</code>, updated by every <code>char.prompt.delta</code> 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. | |
| + | <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. | ||
| − | + | '''Resync any time''': request <code>char.prompt</code> 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 <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. | ||
| − | == | + | ==== <code>char.score</code> — request-only ==== |
| − | === | ||
| − | |||
| − | + | A single snapshot of the full score sheet, grouped: | |
| − | |||
| − | |||
| − | The | + | <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>"Male"</code>, <code>"Female"</code>, or <code>"Other"</code>. <code>pvp</code> is <code>"PKE"</code>, | |
| − | + | <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. | |
| − | The | + | 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>{ "conditions": ["hungry", …] | null }</code> | ||
| + | * <code>char.status.affectedby</code> → <code>{ "affectedBy": [{ "time", "name", … }, …] | 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>{ "time", "name", "disposition" }</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>"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. | ||
| + | * <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>"sanctuary"</code>, <code>"detect_invis"</code>). | ||
| + | * <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. | ||
| + | * <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>{ "timers": [{ "name", "time" }, …] | null }</code> — skill/ability reuse timers. | ||
| + | |||
| + | ==== <code>char.offer</code> — request-only ==== | ||
| + | |||
| + | Mirrors the OFFER command: <code>{ "items": [{ "name", "rent", "count" }, …] | null, "totalRent", "totalItems" }</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>"bold"</code>, | ||
| + | <code>"dim"</code>, <code>"italic"</code>, <code>"underline"</code>, <code>"blink"</code>, <code>"reverse"</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 >= max -> full | ||
| + | cur <= max / 10 -> critical | ||
| + | cur <= max / 4 -> bad | ||
| + | cur <= max * 2 / 3 -> medium | ||
| + | otherwise -> low</pre> | ||
| + | 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. | ||
| + | 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>"It is too dark to see..."</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>"indoors"</code>, <code>"underwater"</code>, <code>"aerial"</code>, <code>"water"</code>, <code>"outdoors"</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>"open"</code>, <code>"closed"</code>, or <code>"locked"</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>"0x1a2b3c4d5e6f7890"</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>{ "oid": "<what you sent>", "found": false }</code>. | ||
| + | |||
| + | ==== <code>object.info</code> — request with <code>{"oid": "..."}</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: { "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>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>[{ "stat", "mod" }, …]</code>, plus <code>{ "stat": "dmgmod", "mod", "pct" }</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>{ "items": [ { …object detail… }, …] }</code>. Each row is a '''complete <code>object.info</code> detail object''' — no per-item follow-up fetch needed. | ||
| + | * '''<code>{"oid": "<container>"}</code>''' → that container’s direct contents: <code>{ "oid", "items": [ … ] }</code>, same full-detail entry shape. A closed container answers <code>{ "oid", "closed": true, "items": [] }</code> (same rule as <code>look in</code>). Not found / not visible → <code>{ "oid", "found": 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>"more": true</code> and <code>"offset": <n></code>; re-send the same request with that | ||
| + | <code>offset</code> added (e.g. <code>{"oid": "…", "offset": 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>"inv"</code> (carried, unworn, top level), <code>"worn:<slot>"</code> (the same slot vocabulary as <code>char.equipment</code>, below), or <code>"in:<container-oid>"</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 -> {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 == "inv"</code>; equipment panel = entries with <code>loc</code> starting | ||
| + | <code>"worn:"</code>; a bag’s contents = entries with <code>loc == "in:<bag's oid>"</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>"unknown"</code> rather than | ||
| + | being dropped. | ||
| + | |||
| + | '''2026-07-14: no more pushed deltas.''' <code>char.equipment</code> used to also | ||
| + | push per-item <code>"full": 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>> 0</code> | ||
| + | |- | ||
| + | | learned, lost the prerequisites | ||
| + | | <code>false</code> | ||
| + | | <code>> 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>["dd","dot"]</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>"none"</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>{ "words": [] }</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>{ "spells": [] }</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>"level": 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>{ "runes": [] }</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>{ "abilities": [] }</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>"Unknown"</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>{"keywords": "..."}</code> ==== | ||
| + | |||
| + | Fetches a helpfile — the same content the <code>HELP</code> command prints, | ||
| + | without the banner. | ||
| + | |||
| + | <pre>help.topic {"keywords": "pk"}</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>"keywords": "summary"</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>{ "entries": [] }</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>{"vnum": N}</code> ==== | ||
| + | |||
| + | Detail for one journal entry: | ||
| + | |||
| + | <pre>char.journal.entry {"vnum": 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>{ "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. | ||
| + | |||
| + | == 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.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), andchar.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.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.
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 },
"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.
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 }—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.
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,
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 except rooms inside transient instances, whose recycled vnums are meaningless and suppressed. The same applies toto_vnumon exits. Don’t key persistent mapper data on instance rooms.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.
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 }—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.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 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.
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 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.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.
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.
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:
- 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, andcolorscurrent (char.colorsarrives on its 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.
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.