Skip to content
Kit Oliynyk edited this page Nov 2, 2026 · 1 revision

TL;DR: DreamLand speaks English, Russian and Ukrainian. Russian is the source language and the fallback. Every line is rendered for the reader's own language, by one of a few mechanisms: multi-language XML fields, a message catalog keyed by the Russian text, explicit three-language messages, inline language tags, and declension pads for names. Use when: you add or change any player-facing text, wonder why a player sees the wrong language, or need to pick the right mechanism for a new string. Source of truth: src/l10n/ (lang.h, translation.h, flexer.cpp, l10n.h), src/core/multimessage.cpp, plug-ins/system/translations.cpp, dreamland_world/config/translations/*.json. Verified: 2026-11-02 against dreamland_code 88da8302, dreamland_world 43880467, dreamland_fenia_public c28874b

The model

Concept Rule
Languages English 0, Russian 1, Ukrainian 2 (lang_t in src/l10n/lang.h). Fenia uses the same numbers.
Source Russian. New text is written in Russian first, then translated.
Fallback Russian. A missing or empty English or Ukrainian value renders as the Russian one, so a half-translated line is never blank.
Reader's language The config lang setting (en, ru, ua), stored as the lang attribute of the character.
Unset, mobs, no viewer Russian (LANG_DEFAULT).
Charmed pet Its master's language.
Switched immortal mob The language of the body it came from.

Characters created before config lang existed had a per-category Russian toggle (rucommands). On login an empty lang is seeded from it (set means ru, clear means en). The toggle itself is retired.

Two resolvers give the reader's language:

  • viewerLang(ch) in core. The output layer, toNoun and MultiMessage use it. It also handles pets and the forced language below.
  • Player::displayLang(ch) in plug-ins/system/player_utils.h, and ch.displayLang in Fenia. Plug-in and script code uses it.

Both return the same value for a player.

Where translatable text lives

Kind of text Mechanism Details
Names, descriptions, keywords of areas, mobs, objects, rooms, skills, races, helps Multi-language XML fields: one element per language with an l="en", l="ru" or l="ua" attribute Area-Format, Help-Articles
Messages built in C++ or Fenia The catalog. The code holds the Russian text, the catalog holds English and Ukrainian below, L10n-Cpp, L10n-Fenia
A message whose three texts are known at the call site An explicit three-language message (MultiMessage(en, ru, ua) in C++, .mm(en, ru, ua) in Fenia, lmsg helpers) no catalog, hot-reloads with the script
Small fragments inside fixed text Inline tags {l, {n, {s Text-Markup
Reader-dependent endings and pronouns Format codes %G, %n, %I and the {S tag Output-Format, Text-Markup
Declined names Flexer pads below
Flag names (item flags, immunities, damage types) Flag tables with per-language names. Fenia reads them with .tables.<table>.messages(bits, case, ch.displayLang) Data-Configs
Command words players type <name l> and <aliases l> in the command XML, plus dreamland_world/grammar/synonyms.json for argument words such as yes, no, all the parser accepts every language at once

Some player text is data, not code, so grepping the C++ for it finds nothing:

  • Channel formats (gossip, say, and so on) live in dreamland_world/commands/channels.xml. They are plain Russian strings translated through the catalog section commands/channels.xml.
  • Spell casting flavour lives in the skill-group files (msgSelf, msgVict, msgRoom, index-parallel lists), translated through the section skill-groups.
  • Mail and note thread names are fields on the thread (English, Russian, Ukrainian pads and a Ukrainian gender).
  • Newbie hints carry a localized label supplied by one helper. Do not retype it.

Fallback chain per accessor

Call When the wanted language is empty
XMLMultiString::getForLang(lang) Russian, then English. Use this for anything printed.
XMLMultiString::get(lang) Nothing. Strict. A bare get(RU) can print a blank line.
Catalog (TranslationManager::run) The Russian key itself.
MultiMessage(en, ru, ua) Russian.
getNameFor(lang) on skills, commands, behaviors Russian, then English.
Object::getShortDescr(lang), obj.getShort(lang), ch.getShort(lang) The prototype's value in the same language, then empty. No cross-language fallback.
%O and %C (through toNoun) Russian, when the reader's language has no name

The getShort row is the dangerous one. An object with its own Russian name and empty English and Ukrainian slots shows the prototype's name to English readers, not the Russian one. Code that splices getShort(lang) into a string gets an empty word for an untranslated slot. Pass the object as an argument to a %O code instead.

The catalog

Files: dreamland_world/config/translations/*.json. Every file in the folder is loaded, the file name is arbitrary and big sections are split into several files. Shape:

{
  "plug-ins/help/helpformatter.cpp": {
    "справка": { "en": "help", "ua": "довідка" }
  }
}

The lookup key is the pair (file key, exact Russian text). There is no search across sections. A value that sits in the wrong section is never found and the reader silently gets Russian.

Text comes from File key
C++ _("..."), l(ch, "...") repo-relative source path, anchored at the last src/ or plug-ins/
Fenia ._("...") name of the script that runs the call, not of the entity the text is about
Scripts of the public Fenia repository the same name with a public/ prefix
Data owners such as channels and skill groups a fixed constant chosen by their code

Loading and reload:

  • The catalog is read at boot and by plug reload most. No reboot is needed for a catalog-only change.
  • A change to the Russian text of a string renames its key. Move the English and Ukrainian values together with it, or the line falls back to Russian without any error.
  • Values are converted from UTF-8 to KOI8-U on load. A character that has no KOI8-U code is dropped silently. Use ASCII punctuation only. The modifier apostrophe ʼ (U+02BC) is replaced by the ASCII ' by the converter on every load path, so readers see ' either way. Write ' in catalog values and ʼ in XML and help text.
  • Placeholders must stay in the same order and have the same letters as in the Russian key. See L10n-Fenia.

Checks that live in the world repository:

Script Finds
scripts/lint-catalog-coverage.py <source files> Wrapped Russian strings that have no complete English and Ukrainian entry
scripts/lint-catalog-placeholders.py Translations whose % codes read a different argument type than the Russian key
scripts/find-foreign-fragments.py Latin words inside Cyrillic text and Latin letters hidden inside a Cyrillic word
scripts/lint-hc-command-lang.py {hc} links whose command no parser accepts

Declension: Flexer pads

Russian and Ukrainian names decline. A declinable string is stored as a pad: a root followed by six case cells separated by |.

STEM|nom|gen|dat|acc|instr|prep

The root is the longest prefix shared by all six forms. Each cell is the ending to append to it. The first cell appended to the root is the nominative, so the root alone is not the nominative.

Pad Forms
меч||а|у||ом|е меч, меча, мечу, меч, мечом, мече
королев|а|и|і|у|ою|і королева, королеви, королеві, королеву, королевою, королеві
ніж|ка|ки|ці|ку|кою|ці ніжка, ніжки, ніжці, ніжку, ніжкою, ніжці

Rules:

  • Six pipes, seven fields, always. An empty cell is ||.
  • Space and hyphen start a new word. Each word carries its own pad. A word without | stays as it is.
  • The root is the shared prefix of all six forms, not the morpheme boundary. Ukrainian к/г/х become ц/з/с in the dative and prepositional, so the root must stop before that letter (ніж + ка, not ніжк + а).
  • Cases are numbered 1 to 6: nominative, genitive, dative, accusative, instrumental, prepositional. Format codes (%C2, %N4, %O1), ruscase(n) in Fenia and russian_case in C++ pick a cell.
  • English never has a pad. Do not put | into an English name.
  • A %s that receives a raw pad prints the pipes. Resolve it with a case code or ruscase.
  • Colour codes inside a pad are fenced by the engine with {1...{2 so they cannot leak into the sentence.
  • Always render a hand-written pad and read all six forms. Matching the shape of the Russian pad proves nothing. A correct ending on a root that cannot carry it is still a non-word.

Names of players and generated names (restrung items, random weapons) are declined by the engine. Ukrainian forms come from a morphology helper that is reached through .declineUa(word[, pos[, gender]]) in Fenia. If the helper is unavailable the call returns the nominative in every case. Foreign names that end in a vowel and some feminine names ending in a consonant do not decline at all, and a pad with six empty cells is the correct answer for them.

Names for per-reader output come through toNoun: %C, %O and %K decline in the reader's language, whatever the language of the template is.

Output with no reader

Some sinks have no character to ask: a stream relayed to Discord, a log line, a broadcast built once. Render them with a fixed language:

  • C++: fmtLang(lang, message, ...), or a ForcedViewerLang scope around one synchronous fmt call.
  • Fenia: .fmtLang(lang, fmt, ...).

The Discord relay is English. Never use a bare fmt(0, ...) or .fmt(...) for it, those render Russian.

Rules that save a review

  • English object short descriptions have no leading article: iron sword, never an iron sword. The templates around them add their own article or none.
  • Do not build a sentence out of translated fragments. Each combination is its own whole string.
  • One alphabet per word, ASCII punctuation, straight quotes. See Writing-Player-Text.
  • A new language-dependent string needs all three languages in the same change. A wrapped string with no catalog entry is a Russian leak for everybody else.

See also

Clone this wiki locally