Skip to content

L10n Cpp

Kit Oliynyk edited this page Nov 2, 2026 · 2 revisions

TL;DR: C++ output is localized by wrapping the Russian literal in _("...") (resolved per recipient) or l(ch, "...") (resolved now for one viewer) and adding the English and Ukrainian text to the catalog. A wrapped line with no catalog entry prints exactly as before, so wrapping is always safe. The work is in choosing the right idiom and in the sites where no viewer is in scope. Use when: you write or change a hard-coded message in src/ or plug-ins/, convert a buf << "русский текст" builder, or find Russian text shown to English or Ukrainian players. Source of truth: src/l10n/l10n.h (_, l, l10n_file_key), src/core/multimessage.h, plug-ins/output/act.h (fmt, fmtLang, lmsg), src/xml/xmlmultistring.cpp, dreamland_world/config/translations/plug-ins.json. Verified: 2026-11-02 against dreamland_code 88da8302, dreamland_world 43880467

The mechanism

Every player-facing line resolves through the recipient's language (viewerLang(ch), see L10n).

  • _(msg) builds a MultiMessage(msg, FILE). The output overloads (pecho, recho, echo, oldact, fmt, say_fmt and others) resolve it separately for each recipient.
  • l(ch, msg) resolves now, for one known viewer, and returns a stable const char *. Use it for string building, table lookups and anywhere a plain C string is needed.
  • The catalog entry is keyed by (file, Russian text) and holds {en, ua}. The file key comes from __FILE__, cut at the last src/ or plug-ins/ component, so it does not depend on the build layout (plug-ins/comm/look.cpp). A file with neither component degrades to Russian only.
  • Russian is byte-identical. For a Russian reader _() returns the source string itself. Zero regression is the invariant: a line you wrap but do not translate must print exactly what it printed before.

Include l10n.h only in .cpp files that wrap literals. It defines the function-like macros _ and l, and a header that includes it leaks them into every file that follows.

Idioms

You have Write
ch->pecho("рус", args) ch->pecho(_("рус"), args)
buf << "рус" buf << fmt(ch, _("рус")) (needs act.h and l10n.h)
buf << "рус " << x << " рус2" buf << fmt(ch, _("рус %1$s рус2"), x), one whole sentence with numbered codes
A literal where no fmt recipient exists buf << _("рус").getMessage(viewerLang(ch))
X.get(RU) or .get(LANG_DEFAULT) on data, a viewer in scope X.getForLang(Player::displayLang(ch))
room->getName(), area->getName() (default language) ->getName(viewerLang(ch))
skill->getName(), command->getName() ->getNameFor(ch) or ->getNameFor(lang)
fmt(0, _("рус"), ...) with a viewer available fmt(ch, _("рус"), ...)
A broadcast that names a direction direction_word(viewerLang(ch), door, DIR_CASE_TO), or store LangText dir = direction_langtext(door, dcase); and pass &dir to a %w code so each reader gets the word in their language. %w reads a LangText *, so passing the struct by value compiles and then crashes
A message whose three texts are in hand MultiMessage(en, ru, ua) (no catalog)

fmt(0, ...) and fmt(NULL, ...) have no recipient and resolve to Russian even when English and Ukrainian exist in the catalog. This is the most common leak.

fmt(ch, _("literal")) treats the resolved string as a printf format. It is safe only if the literal has no bare % or $. Colour tags pass through.

For a sink with no viewer use fmtLang(lang, _("..."), ...). See L10n.

Is there a viewer in scope?

Check every site, not every file. A raw-stream builder can live in a helper that has no Character * (a list printer that only takes a std::ostream &). Wrapping fmt(ch, _()) there does not compile, and the fix is a different design:

  • The helper has a ch parameter, or the class has a Character *ch member: wrap it.
  • Neither: add a Character * or lang_t parameter and thread it through the callers, or store the viewer in a member set before display, or defer the site.

Read the enclosing function signature yourself before you skip a site because a search tool claimed it has no viewer. Pattern-based finders mistake control lines and buf << f(this, ch) for function headers.

Data-driven strings

Some player text is data: socials, area strings, help bodies. It lives in XMLMultiString fields and never touches the catalog. Build the per-viewer message from the three-language constructor and give it to the MultiMessage overloads of oldact, pecho and recho.

Two traps:

  • XMLMultiString::fromXML guesses the language of a legacy node with no l attribute from its content: Cyrillic is Russian, anything else is English. A wordless Latin line (***BONK***) lands in the English slot.
  • The Russian slot of a MultiMessage has no fallback. getMessage falls back to Russian for an empty English or Ukrainian slot, but hands a Russian reader the Russian slot as it is. Combined with the first trap, a bare get(RU) prints a blank line to every Russian reader.

So build from getForLang(lang) (falls back Russian, then English), never from get(lang). Keep legacy const DLString & getters on getForLang(RU), that reproduces the old single-value behaviour.

Converting a field to XMLMultiString is source-compatible for reads and not for OLC editing. editor(argument, field, ...) wants a DLString &, so pass field[LANG] (see redit.cpp, runame and uaname) or give the edit session a language (socedit.cpp, language en|ru|ua). A header type change breaks callers in other plugins and a compile of the changed files will not show it. Grep the field name across *.cpp and *.h before you push.

Speaking helpers: keep the frame, wrap the format

Several helpers add a speech frame of their own and choose it by overload. tell_raw(ch, npc, const char *, ...) hard-codes the Russian frame "%^C1 говорит тебе '{G", on purpose, so that unwrapped callers stay byte-identical. The MultiMessage overload builds the frame in all three languages.

// English reader gets: The quest keeper говорит тебе '...'
tell_raw(pch, questman, "%s", text.getForLang(viewerLang(pch)).c_str());

// Frame localized, data stays an argument:
tell_raw(pch, questman, _("%s"), text.getForLang(viewerLang(pch)).c_str());

"%s" has no catalog entry, so every language falls back to the Russian key, which is "%s" itself. The format is identical everywhere and only the frame changes. Keep the data as an argument. Building the format out of the data makes any % in the data a conversion.

The same trap exists when buf is already localized (fmt(ch, ...)) and is then passed as a plain char * to tell_raw: wrap it as MultiMessage(s, s, s) so the frame matches.

Plural, gender and Russian-only codes

  • %I count agreement replaces the old GET_COUNT(n, "а", "ы", "") ending injection, which leaks the Russian ending into the other languages. The three forms are for 1, for 2 to 4, for everything else: Russian минут%1$Iа|ы|, English minute%1$I|s|s, Ukrainian хвилин%1$Iа|и|. A form ends at a space, so a multi-word branch needs two %I.
  • %G takes neuter, masculine, feminine, plural in that order. English drops it and restructures the sentence to be genderless. Ukrainian keeps it.
  • %P, %p, %T use a hard-coded Russian table. Never emit them in English or Ukrainian text, reword. For English use %nit|them and for Ukrainian %Gйого|його|її|їх.
  • %C with a case digit is the character noun and %O the object noun. Both localize per reader. %N only declines the string it is given, in whatever language that string is, so pass a per-language string (getForLang(viewerLang(ch)))

See Output-Format for the full code table.

When to defer a site

The goal is that every output line is in one language. Do not ship a mixed line.

  • A Russian-only declined noun inside a localized frame (getShortDescr('6'), .ruscase('N'), a Flexer pad returned as a plain string). Wrapping only the frame gives Opens a lock on дверь., which is worse than leaving it Russian. Wait until the noun has a per-language renderer (getForLang, a MultiInflectedString, or toNoun(viewer)).
  • Fixed-width tables with manual space padding. A localized header changes the width. Re-pad per language or defer. A screen-reader branch (config screenreader) is needed anyway.
  • Broadcasts where the subject is not the recipient (clan talk, room echoes, announcements). They need a per-viewer primitive that takes a MultiMessage, not a one-time wrap. Helpers that call fmt(NULL, ...) render once in Russian for everybody.
  • Command keywords in help or usage text. Localize only to words the parser actually accepts (check dreamland_world/grammar/synonyms.json, which is JSON5, edit it as text). A dead Ukrainian keyword is an inaccuracy. Add the synonym first.

The catalog entry

  • The key is the Russian source string verbatim, the exact bytes the runtime looks up.
  • Put the English where it already exists in the original DikuMUD, ROM or Anatolia text (Anatolia is the closest ancestor). Write fresh English otherwise.
  • Merge under the plug-ins/<dir>/<file>.cpp key in dreamland_world/config/translations/plug-ins.json.
  • A composed sentence never resolves. The key is the finished Russian sentence, and anything assembled from fragments is not in the catalog. Write each combination as one whole _() literal. Four booleans means four literals.
  • A catalog key is (file, Russian phrase), so one Russian word cannot carry two genders in one file. If the same bare word is used for a masculine and a feminine noun, give one site a distinct source phrase.
  • Editing a Russian literal renames its catalog key. Move the English and Ukrainian values in the same change.
  • Use ASCII punctuation. In catalog values write the Ukrainian apostrophe as ASCII '. See L10n.
  • Run scripts/lint-catalog-coverage.py <file.cpp> and scripts/lint-catalog-placeholders.py from the world repository. The first lists wrapped strings without a complete entry, the second finds % codes that read a different argument type than the Russian key.

Transfer, hints and other helpers with a MultiMessage twin

Helper Note
transfer_char(ch, actor, room, mRoomLeave, mSelfLeave, mRoomEnter, mSelfEnter) The MultiMessage overload requires its first three message arguments, so message-less calls stay on the const char * overload.
hint_fmt, echo_master, echo_char, say_fmt, tell_fmt, tell_raw All have a MultiMessage overload. Use _() with them.
direction_word, direction_doorname Per-language direction and door words. English is caseless, so the English frame carries the preposition.

See also

Clone this wiki locally