Skip to content

Client Text and Translation

This guide shows how to read, edit and translate the client text table, and how to add a new language. Every message in the client windows comes from this table, so most “wrong text on screen” bugs end here. Read it with Add an Item, because new items need text rows too.

All file names below come from the source code that loads them. Anything marked not confirmed yet was not verified in code or in a file.

The client loads one text file at login. The file name depends on the locale (country build) the client runs as.

Locale class (LSClient\Local\) File name
ioLocalKorea text.txt
ioLocalIndonesia text_id.txt
ioLocalUS text_us.txt
ioLocalTaiwan text_tw.txt
ioLocalThailand text_th.txt (2014), text.txt (2019, 2021)
ioLocalChina, ioLocalJapan text.txt
ioLocalPhilippine (2019, 2021) text_id.txt

The load happens in ioApplication::SetupBeforeLogin. It reads config\sp2.ini, key path in [resource], and loads <resource>\text\<file name> through ioStringManager::LoadData (io3DEngine\ioStringManager.cpp). So the Indonesia file is <resource>\text\text_id.txt. It is inside the client packs, not in the unpacked folders.

Each locale class also holds a small built-in list (GetMemTextList) for errors that appear before the file is loaded, for example the game guard messages. Those lines are in the C++ code, so changing them needs a rebuild.

[v2012082901]
|EXE_BankruptcyCharWnd::UpdateFirstClass_1|Hired %s for %d hours|
|EXE_AwardSlotWnd::OnRender_2|Peso +%d%%|

How LoadData reads it:

  • A row is |KEY|text|. Only the text between the bars is used. Anything outside the bars is ignored. Lines such as [v2012082901] are free to use as version notes.
  • The key is lower-cased when it is stored, so key case does not matter.
  • The first row of a key wins. If a key appears again, the loader skips it and writes duplicate Text :Line<n> to the client log. When you override a row, edit the existing one. Do not add a second one at the end.
  • A literal | cannot be used inside the text. It ends the row.
  • Key and text are each cut at 520 bytes (PATH_SIZE). More characters are dropped without a message. This is from reading the loop. A test with a long row was not done yet.
  • A line break (CRLF) inside an open row logs Error Text : Line<n> and drops that row. This is the only place CRLF matters. The source file LSClient\text.txt (2019, 2021) is CRLF and CP949. The 2021 client file resource\text\text.txt has plain LF line ends, and the loader accepts that, because it only follows the bars. Keep CRLF anyway so the error check works.
  • The two characters \n and \r inside the text become real line breaks when loaded.
  • The file is read as bytes. Save it in the code page the build expects. A Korean build treats double-byte characters as one letter; the exact code page for the Indonesia build is not confirmed yet (the loader reads COUNTRY_CODE_PAGE, which is set per build).

The shipped 2021 client table has 6477 rows and no repeated key (counted for this page).

The STR(n) macro (io3DEngine\ioStringManager.h) builds the key from the function name, not the file name:

EXE_<Class::Function>_<n>

Example: FirstHireWnd.cpp contains the function BankruptcyCharWnd::UpdateFirstClass. Its line STR(1) reads the row EXE_BankruptcyCharWnd::UpdateFirstClass_1. The number n is the number written in the code. It does not have to be continuous.

If a key is missing, STR(n) returns the key itself. A window that shows EXE_... on screen means the row is missing or spelled differently.

Two other prefixes exist in the loader:

Prefix Used by Note
XML_<name>_<n> window XML text written as STR(n) (GetStringXML) The shipped 2021 client table has no XML_ rows. How the <name> part is built: not confirmed yet
INI_<file>::<section>_<n> INI values written as STR(n) (GetStringINI) The shipped 2021 client table has no INI_ rows

This is the rule that causes most broken screens. SafeSprintf (io3DEngine\Safesprintf.cpp) fills the placeholders in one of two ways:

Client Placeholder style Example
Korea locale, 2019 and 2021 printf style %s, %d, %u, %f Hired %s for %d hours
Every other locale numbered style %1, %2 … %99 Hired %1 for %2 hours

The switch is ioLocalManagerParent::IsVPrintf(). Differences per version:

2014 2019 2021
IsVPrintf() always false (the Korea test is commented out) true only for Korea true only for Korea
Style the text file must use %1 %2 for every locale %s %d for Korea, %1 %2 for the rest same as 2019

If the style is wrong, nothing crashes. The check function fails and the raw row is copied to the screen, for example Hired %s for %d hours with the signs still in it. A raw %d or %1 in a window is always this problem.

What the checks require:

  • Numbered style: the number of placeholders must be exactly the number of values the code passes. Every number must be between 1 and that count. The values are turned into text first, so %1 works for numbers and names alike.
  • printf style: the placeholder types must match the order and types of the code values (%d for a number, %s for text, %f for a float).
  • Keep the same count when you translate. You may change the order in the numbered style: %2 hours for %1 is valid. In the printf style the order is fixed.
  • A literal percent sign is written %% in both styles (the shipped row Peso +%d%%). A single % that is not a placeholder breaks the whole row.

The shipped table uses # as a line break (272 rows in the 2021 client table, for example Problem detected in client file.#Please restart after closing.). It is not handled by the loader. Each widget decides:

  • ioRichLabel and window XML text (ioGUIManager, SetText(..., "#")) turn # into a new line.
  • Other code replaces # with a space (ManualWnd, ioModeHelpManager, ioGameAnnMgr, RollBookWnd, ioMission, ioLoadingToolTipRender).

So the same row can wrap in one window and run on in another. Which one a message box does: not confirmed yet. Use \n only if a test shows the window does not convert it.

Some windows make the player type a word before a destructive action (sell, disassemble, reset). The code compares the typed text with a string. Two cases exist, and the second one is a trap.

Case Where the compared word lives What you must do
Compared with a text row strcmp( pEdit->GetText(), STR(n) ) Translate the row. The prompt and the compared word must stay the same text
Compared with a literal the word is a quoted Korean string in the C++ code Translating the table changes nothing. The player still has to type the Korean word, until you change the code and rebuild

ItemResellWnd.cpp shows both:

Action 2014 2019 2021
Sell gear (also costume, accessory in 2014) STR(2) STR(2) STR(2)
Disassemble gear STR(8) STR(14) Korean literal
Sell medal STR(11) STR(17) Korean literal
Sell costume STR(2) (same word as sell) STR(20) Korean literal
Sell accessory STR(2) (same word as sell) STR(23) Korean literal

The word is the middle one of three rows (STR(1) asks, STR(2) is the word, STR(3) is the error text in 2021; the other actions use the next groups of three). The same pattern is in PetResellWnd.cpp (STR(2)), RecordInitWnd.cpp (STR(1)), ItemCustomWnd.cpp (STR(1), STR(3)) and MyInventoryInfoWnd.cpp (STR(2) for dismissal, a Korean literal for disassemble).

In the 2021 table the compared word of ItemResellWnd is the row EXE_ItemResellWnd::iwm_command_2. The screen label is built from other rows (OnRender_1, OnRender_2). After a translation, open the window and type exactly what is shown between the brackets.

To make a literal translatable, replace the literal with STR(n), add the rows, and rebuild the client. Check each window for the same pattern before you ship a language. A full list of literal comparisons across all windows: not confirmed yet.

  1. Extract text_id.txt (or the file of your locale) from the client pack with LosaDev Tools: Extract and replace a file.
  2. Open it in an editor that keeps the code page and CRLF. Do not use a program that rewrites the encoding.
  3. Find the row by key, or search the English or Korean text.
  4. Edit only the text between the second and third bar. Keep the placeholder style of your client (see above) and the same number of placeholders.
  5. For new keys, copy an existing row and change the key to match EXE_<Class::Function>_<n> exactly.
  6. Put the file back with the same tutorial and start the client. Look for raw %, EXE_ text and broken lines in the windows you changed.
  7. Check the client log for duplicate Text and Error Text lines.

The language is chosen in code, not by a config key. The steps below come from reading ioLocalManager.cpp and ioLocalIndonesia.cpp:

  1. Copy LSClient\Local\ioLocalIndonesia.cpp and .h to a new class, for example ioLocalXxx. Set GetType() to a new enum value, and GetTextListFileName() to "text_xx.txt". The class must also implement all other pure virtual functions of ioLocalParent (resource ids, login data, guild rank names, billing messages and so on).
  2. Add the enum value to io3DEngine\ioLocalManagerParent.h (LocalType).
  3. Register the class in ioLocalManager.cpp in CreateLocal, Init and GetLocalName.
  4. Add the value to the allow list in ioLocalManager::ParseCmd. A value that is not in the list is ignored and the client stays on the default, which is Korea.
  5. Check the places that test the locale: GetNativeIMEConvMode, IsIME, GetChangedFontScale and the font settings in ioLocalManagerParent.cpp.
  6. Make the translated text_xx.txt in the numbered placeholder style (see above).
  7. Rebuild the client and pack the new text file into <resource>\text.

How the locale value reaches the client: ParseCmd reads the tenth comma field of the start command, before the first ?. Who builds this command (launcher or web broker) was not checked, so how to start the client in the new locale: not confirmed yet.

2019 and 2021 contain an ioLocalPhilippine class that is not registered in Init or CreateLocal, so it cannot be selected. It is a good model for what a half-finished locale looks like. 2014 has more locale classes (Singapore with text_my.txt and text_sg.txt, EU with six languages, Latin with text_sa.txt) that the later sources removed.

The game server has Local classes with the same GetTextListFileName functions. A search of ls_gamesvr found no use of the string manager outside them. The server texts you see in game are therefore not translated by this file. This was not searched exhaustively.

2014 2019 2021
Source text file none in the folder LSClient\text.txt (CP949 Korean) LSClient\text.txt (CP949 Korean)
Unpacked client resource\text not present (packed) not present (packed) text.txt, plus KR\text.txt and gw\text.txt
Placeholder rule %1 %2 everywhere Korea %s %d, others %1 %2 same as 2019
Typed words 3 groups of rows 5 groups of rows sell row only, 4 Korean literals
Registered locales 10 (with Singapore, EU, Latin) 7 7

What KR\text.txt and gw\text.txt in the 2021 client are used for: not confirmed yet. Their sizes differ from text.txt.

  1. Start the client in the target locale and open every window you edited.
  2. Look for raw %, raw EXE_ keys and words that wrap in the wrong place.
  3. Try each typed-word window with the shown word. A message that never accepts the word means the prompt and the compared row differ, or the word is a code literal.
  4. Open the client log and search for duplicate Text and Error Text.

Not confirmed yet: the code page used by the Indonesia build, and which windows turn # into a line break in message boxes.