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.
How the text table is built
Section titled “How the text table is built”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.
The row format
Section titled “The row format”[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 fileLSClient\text.txt(2019, 2021) is CRLF and CP949. The 2021 client fileresource\text\text.txthas 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
\nand\rinside 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).
How code finds a row
Section titled “How code finds a row”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 |
Placeholders: %s %d or %1 %2
Section titled “Placeholders: %s %d or %1 %2”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
%1works for numbers and names alike. - printf style: the placeholder types must match the order and types of the code values (
%dfor a number,%sfor text,%ffor a float). - Keep the same count when you translate. You may change the order in the numbered style:
%2 hours for %1is valid. In the printf style the order is fixed. - A literal percent sign is written
%%in both styles (the shipped rowPeso +%d%%). A single%that is not a placeholder breaks the whole row.
Line breaks with #
Section titled “Line breaks with #”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:
ioRichLabeland 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.
Words the player must type
Section titled “Words the player must type”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.
Translate or edit text
Section titled “Translate or edit text”- Extract
text_id.txt(or the file of your locale) from the client pack with LosaDev Tools: Extract and replace a file. - Open it in an editor that keeps the code page and CRLF. Do not use a program that rewrites the encoding.
- Find the row by key, or search the English or Korean text.
- 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.
- For new keys, copy an existing row and change the key to match
EXE_<Class::Function>_<n>exactly. - 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. - Check the client log for
duplicate TextandError Textlines.
Add a language
Section titled “Add a language”The language is chosen in code, not by a config key. The steps below come from reading ioLocalManager.cpp and ioLocalIndonesia.cpp:
- Copy
LSClient\Local\ioLocalIndonesia.cppand.hto a new class, for exampleioLocalXxx. SetGetType()to a new enum value, andGetTextListFileName()to"text_xx.txt". The class must also implement all other pure virtual functions ofioLocalParent(resource ids, login data, guild rank names, billing messages and so on). - Add the enum value to
io3DEngine\ioLocalManagerParent.h(LocalType). - Register the class in
ioLocalManager.cppinCreateLocal,InitandGetLocalName. - 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. - Check the places that test the locale:
GetNativeIMEConvMode,IsIME,GetChangedFontScaleand the font settings inioLocalManagerParent.cpp. - Make the translated
text_xx.txtin the numbered placeholder style (see above). - 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.
Differences between versions
Section titled “Differences between versions”| 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.
Check your change
Section titled “Check your change”- Start the client in the target locale and open every window you edited.
- Look for raw
%, rawEXE_keys and words that wrap in the wrong place. - 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.
- Open the client log and search for
duplicate TextandError Text.
Not confirmed yet: the code page used by the Indonesia build, and which windows turn # into a line break in message boxes.
