Skip to content

L10n Fenia

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

TL;DR: In Fenia, wrap the Russian text in ._("...") and pass the result straight to act, say, recho, echo or psay. The engine picks English, Russian or Ukrainian per reader from the catalog. Texts built in the script itself are never translated, so build them from whole ._() sentences or from .mm(en, ru, ua). Use when: you write or change a message, name or prompt in a Fenia script, or players report Russian text in English or Ukrainian output. Source of truth: plug-ins/feniaroot/root.cpp (_, mm, fmt, fmtLang), plug-ins/feniaroot/dlprintf.cpp (RegFormatter), plug-ins/feniaroot/multimessagewrapper.h, src/fenia/register-impl.h (operators), dreamland_fenia_public/utils/system (lmsg). Verified: 2026-11-02 against dreamland_code 88da8302, dreamland_world 43880467, dreamland_fenia_public c28874b

Choosing the tool

You have Use
A fixed Russian sentence ._("Русский текст"), translated in the catalog
Three texts known right here, or a name that cannot go in the catalog .mm(en, ru, ua)
A frame chosen by language, plain string result .tmp.system.lmsg(ch.displayLang, en, ru, ua) (public helper)
A sink with no reader (a Discord relay) .fmtLang(lang, fmt, args), language 0 English, 1 Russian, 2 Ukrainian
A flag name (item flag, immunity) .tables.<table>.messages(bits, case, ch.displayLang)
A name of a mob, object, room, area pass the entity as an argument to %C, %O or %N, or use getName(lang) and getShort(lang)

Look every method up with the API search before you use it. Names and signatures come from feniaapi.html and api(), not from this page. See Fenia-API.

._() in one page

ch.act(._("Ты обжигаешь %1$C4."), vict);
vict.act(._("%1$^C1 обжигает тебя."), ch);
ch.recho(._("%1$^C1 обжигает %2$C4."), ch, vict);
mob.say(._("Добро пожаловать!"));
  • ._(text) returns a message object. act, say, psay, recho, rvecho, echo and echoAround accept it as the format and resolve it separately for each recipient. It also works as a %s argument, and a message object stored in a variable or passed to another function keeps working within the running script. Message objects are never persisted: one stored in .tmp or in a field on a character or object is lost at reboot. Store the key or the Russian text and call ._() again when you need it.
  • The object is opaque. It has no fields and no methods. "text" + msg and msg + "text" throw, and so does msg == "" or msg == "text". A helper that tests its message parameter against an empty string breaks the first time a caller passes a ._(). Pass messages through, do not inspect them. A local variable that you set to "" and later assign a ._() has the same problem when you compare it.
  • ch.print(fmt, args) and .fmt(fmt, args) build a string. ch.print renders for that character. .fmt has no recipient and renders Russian. A translated line built with .fmt is a Russian line.
  • RU is the key and the fallback. A string with no catalog entry prints the Russian text for everyone.
  • Never concatenate a ._() with other text. Write each variant as its own full sentence:
// wrong: throws at run time, the whole trigger silently stops
msg = ._("Ты видишь ") + what;

// right: one sentence per case, the noun stays an argument
ch.act(._("Ты видишь %1$O4."), item);

Where the catalog entry goes

The catalog section is named after the codesource that is running the ._() call, not after the mob, object or spell the text is about. A line that a mob says inside a global login trigger is looked up under the trigger's section. A behavior's lines are keyed by the behavior script name.

Script Section
Any script of the private repository its codesource name, for example spell/<name>/runVict, behavior/<name>, skillcommand/<name>/run
Any script of the public repository the same name with a public/ prefix (public/utils/room)

Consequences:

  • Moving code that contains ._() to another script orphans its catalog entries. The build is green and every English and Ukrainian reader gets Russian. Move the section with the code.
  • A section name that does not match the running script is never read. A byte-exact key under the wrong section is as good as missing.
  • The key is the Russian text after the Fenia lexer ran: \n, \t and \" are already decoded, and adjacent string literals on several lines are already joined. Compare the joined text, not the first fragment.
  • Editing the Russian text renames the key. Move the English and Ukrainian values together with it.
  • A message produced by a helper is keyed under the helper's script, because that is where the ._() runs. Wrap at the place that owns the text.

To check a fix without waiting for the trigger, check the catalog: the entry must sit under the section of the emitting script, with the exact Russian key and both English and Ukrainian values. See L10n and the scripts in the world repository.

Placeholders in catalog values

The formatter binds arguments by position. A translation must keep the same codes in the same order as the Russian key, or an argument lands on the wrong code. A noun code that receives a string or a number throws or reads garbage.

  • Prefer numbered codes (%1$C1, %2$O4). They can repeat and be reordered inside a translation. An existing key written with unnumbered codes stays unnumbered, because adding $ renames the key.
  • Codes that read a different type from the Russian one (%G against %g) throw or read garbage at that position. A dropped code shifts every later argument when the codes are unnumbered. scripts/lint-catalog-placeholders.py in the world repository finds them.
  • Alternations (%N$n, %N$G, %N$I, %N$g) end at a space, -, ,, ., :, ?, !, {, ( or ). A multi-word branch leaks its pipes to the player. Keep only the differing token inside the alternation and move the shared words outside: %1$ndries|dry up., not %1$ndries up|dry up..
  • ^ after the $ capitalizes the argument: %1$^C1. A bare %1$C1 never capitalizes, so a sentence that starts with a name needs the caret.
  • %P, %p and %T always print Russian. In an English template write %1$nit|them, in Ukrainian %1$Gйого|його|її|їх. This also applies to strings picked by lmsg.
  • Gender comes from one stored value per character or object, the same in all languages. Natural Ukrainian nouns whose gender differs from the Russian are written naturally. If a %G or a declension pad in the same string must agree with the object, make it agree inside that string.
  • A message passed as a %s argument is resolved to one string, but its own % codes are not run again. A ._("%C1 ...") used as a %s argument prints %C1 literally. For a spoken or echoed line with arguments, put the frame and the content in one ._() and pass the arguments to the outer call. A message passed as %s should be a plain noun or a finished .mm(en, ru, ua).

Full code table: Output-Format.

Names and descriptions you compose

The plain setters write the Russian slot only:

  • obj.short_descr = x, obj.description = x
  • mob.short_descr = x, mob.long_descr = x, mob.description = x

English and Ukrainian readers then see the prototype's text, with none of your changes. Use the per-language setters and write all three:

obj.setShort("iron sword of " + owner_en, 0);
obj.setShort("железный меч " + owner_ru, 1);
obj.setShort("залізний меч " + owner_ua, 2);

Matching methods exist for the other fields: setDescr on objects and mobs, setLong on mobs and setKeyword on mobs and objects. Build the English and Ukrainian text from the prototype read explicitly (obj.pIndexData.getShort(lang), a mob index's getLong(lang)). Read the prototype explicitly. It states the intent and survives an instance that already has its own slot set.

getShort(lang) returns the raw Flexer pad, pipes included, and does not fall back to another language. To print an object, pass it as an argument to a %O code. When you must build a string, flatten with .ruscase(1), which picks the nominative cell and leaves a plain English string unchanged.

A prototype whose short description contains %s is usually a template that a generator fills in. Do not strip the %s without finding the generator. Make the generator write all three languages.

Colour a name with care: a script that recolours with short_descr = colours only the Russian slot. Loop over the three languages. English names are plain text, so colouring them is safe.

For a declined proper name in a broadcast, pass the three forms as one message argument. The formatter resolves it for every reader and declines it with %N:

var zone;
zone = ch.in_room.area;
ch.recho(._("%1$^C1 входит в %2$N4."), ch,
    .mm(zone.getName(0), zone.getName(1), zone.getName(2)));

Materials expose name, nameRus and nameUa. The Ukrainian and Russian ones are pads. .fmt("%1$N2", pad) declines any pad to the genitive. A pad whose six case cells are all empty declines nothing, and composed Ukrainian text shows the nominative everywhere. Check the data when a composed Ukrainian line reads wrong.

Rooms with mixed languages

recho, rvecho, echo and echoAround format once per recipient, so one call can show every reader their own language. Their audiences differ (who is skipped for position, sight or sleep), so pick the primitive for its audience first and its language second. A hand-written loop over room.players skips none of them, check the filter you need.

The exit direction word is available per language: room.dirMsgLeave(exit, lang).

Mobs that talk

mob.say("Русский текст") prints the Russian text verbatim to every listener. Only say(._("...")) goes through the catalog. say(.fmt("...")) is the same leak, because .fmt already produced a Russian string. Write say(._("...%1$s..."), arg).

If a script defines its own say helper that builds a string (interpret("tell " + vict + " " + msg)), wrapping the helper's calls in ._() crashes it with a cast error. Make the helper pass the message through to act, as the quest dialogue helpers do, rather than wrapping and unwrapping the calls.

Public helper: lmsg

var frame;
frame = .tmp.system.lmsg(ch.displayLang, "You see a door.", "Ты видишь дверь.", "Ти бачиш двері.");
ch.act(frame);

lmsg returns one of three strings. It is the interim tool for sites that cannot use the catalog. All three strings must contain the same %N$ codes, because the same arguments are passed whatever the language. Command links inside the frame translate with it: write the command in each language (get dagger, взять кинжал, взяти кинджал). Prefer ._() for new code, it moves the English and Ukrainian text out of the script.

Transliteration

Two transliterators exist and they differ. The Fenia helper .tmp.system.translit is positional (an initial е becomes ye, a later one e) and it is what the new-character flow runs, so it decides the login of a character with a Cyrillic name. The C++ String::translitToLatin is context-free (е is always e) and is used for the English display name. Егор gives Yegor in Fenia and Egor in C++. A C++ lookup that transliterates a raw name and compares it with a stored login can miss silently. Treat the Fenia result as the login.

Strings and apostrophes

A Ukrainian apostrophe ʼ inside a single-quoted Fenia string breaks the parse. The source goes through the same converter as other text, which turns ʼ into ASCII ', and that ends the string early. The script then silently keeps its old version when you post it (observed on a live post, 2026-09). Put every Ukrainian text with an apostrophe in a double-quoted string. The game log shows the parse error, the post response only says the file failed to evaluate.

Punctuation rules for player text: Writing-Player-Text.

Checklist for a new message

  1. Russian text in ._(), one whole sentence per variant, numbered codes.
  2. The arguments are entities or plain nouns. Names are passed, not spliced.
  3. English and Ukrainian entries under the section of the script that runs the call (public/ prefix for the public repository).
  4. Same codes, same order, same colour tags in each language.
  5. No %P, %p, %T in English or Ukrainian.
  6. No comparison, concatenation or emptiness test on the message object anywhere on its path.
  7. A live trigger or a catalog check for the path, not only a successful post of the file.

See also

Clone this wiki locally