Every user-facing string in CTLD is translatable. Source code never emits raw text to players —
it calls ctld.tr("English text"), and the i18n layer resolves that to the active language at
runtime. CTLD ships four dictionaries: English (reference), French, Spanish and Korean.
The i18n subsystem is deliberately split into logic and data. src/CTLD_i18n.lua holds the
CTLDi18n singleton, the ctld.tr() function and the audit helpers — no strings. Each language
lives in its own CTLD_i18n_XX.lua dictionary file, maintained independently (often by different
translators). A PowerShell tool keeps those dictionaries in sync with the keys actually used in
source.
| File | Language | Role |
|---|---|---|
CTLD_i18n.lua |
— | Class logic only: CTLDi18n singleton, ctld.tr(), audit helpers. No dictionary data. |
CTLD_i18n_en.lua |
English | Reference dictionary. Every value equals its key. Never empty. |
CTLD_i18n_fr.lua |
French | Empty value "" = untranslated (falls back to EN). |
CTLD_i18n_es.lua |
Spanish | Same rule. |
CTLD_i18n_ko.lua |
Korean | Same rule. |
All five files are listed — in this order — in tools/build/listToMerge.txt, so CTLD_i18n.lua
loads before any dictionary and the reference en dictionary loads first.
The active language is selected at the top of src/CTLD_i18n.lua:
ctld.i18n_lang = "en"
--ctld.i18n_lang = "fr"
--ctld.i18n_lang = "es"
--ctld.i18n_lang = "ko"ctld.tr() translates a key to the active language and substitutes any positional parameters.
ctld.tr("Troops loaded")
ctld.tr("Sorry you must wait %1 seconds before you can get another crate", waitTime)
ctld.tr("%1 loaded %2 vehicles into %3", pilotName, count, zoneName)Rule: the key passed to ctld.tr() is ALWAYS a complete string literal. Variable parts are
expressed with %1, %2, … placeholders inside the literal; dynamic values are passed as
separate arguments. Never build a key by concatenation:
-- FORBIDDEN — breaks the static scan, keys go silently missing from dicts
ctld.tr("prefix_" .. suffix)
ctld.tr(someVariable)This matters because generate_i18n_dicts.ps1 extracts keys with a regex. If every key is a
literal the scan is 100% reliable; concatenation creates blind spots.
ctld.i18n_translate is a backward-compatibility alias for ctld.tr — new code uses ctld.tr.
ctld.tr() never returns nil or an empty string. It resolves in this order:
- The active-language dictionary (
ctld.i18n[ctld.i18n_lang]). - The English dictionary.
- The key itself (which is the English text).
An unknown active language logs a WARN and falls back to en.
Each dictionary carries its own translation_version, a "MAJOR.MINOR" string:
ctld.i18n["en"].translation_version = "1.8"Versions are independent per dictionary — dicts are maintained separately, so each one is
bumped only when it changes, not when a sibling changes. ctld.i18n_check() and
ctld.i18n_audit() flag a mismatch between EN and another language as a signal that the other
dictionary may be lagging behind EN and needs review. A mismatch is a warning, not an error: the
fallback chain still resolves every key.
tools/build/generate_i18n_dicts.ps1 scans every src/*.lua file (excluding the dictionaries
themselves) for ctld.tr() keys, then reconciles each dictionary against that set. Run it after
any ctld.tr() addition or removal in source.
# Audit only — reports what would change, writes nothing (default)
powershell -ExecutionPolicy Bypass -File tools\build\generate_i18n_dicts.ps1
# Apply — add missing keys, mark stale ones, bump versions
powershell -ExecutionPolicy Bypass -File tools\build\generate_i18n_dicts.ps1 -ApplyPer dictionary, -Apply performs:
| Situation | Action |
|---|---|
| Key used in source, absent from dict | Appended at end of file. EN gets value = key; other langs get value = "" (untranslated). |
| Key in dict, no longer used in source | Its line is prefixed with -- STALE: — never deleted, so a human confirms before removing. |
| Any change made to the dict | That dict's own translation_version is bumped (patch component +1). |
The tool only rewrites the dictionary files — it does not regenerate any loader and does not
touch listToMerge.txt. The merge order that assembles CTLD.lua is driven entirely by
listToMerge.txt (see Architecture).
- Call
ctld.tr("My new text")in the source module (literal key,%1… placeholders for variables). - Run
generate_i18n_dicts.ps1 -Apply. The new key is appended to all four dictionaries — with the English text as the EN value and""in the others — and each modified dict's version is bumped. - Translators fill in the empty values in
CTLD_i18n_fr/es/ko.lua. - Rebuild
CTLD.lua(tools\build\merge_CTLD.ps1) and add busted coverage for the new logic.
To add a fifth language XX:
-
Copy
src/CTLD_i18n_en.luatosrc/CTLD_i18n_XX.lua, replace everyctld.i18n["en"]withctld.i18n["XX"], and translate the values (keep the keys identical to EN). -
Register the file in
tools/build/listToMerge.txt, after the existing dictionaries. -
Register the language in the
$DictFilesordered map intools/build/generate_i18n_dicts.ps1so the sync tool tracks it:$DictFiles = [ordered]@{ "en" = (Join-Path $DictDir "CTLD_i18n_en.lua") "fr" = (Join-Path $DictDir "CTLD_i18n_fr.lua") "es" = (Join-Path $DictDir "CTLD_i18n_es.lua") "ko" = (Join-Path $DictDir "CTLD_i18n_ko.lua") "XX" = (Join-Path $DictDir "CTLD_i18n_XX.lua") }
-
Add the language as an option in
src/CTLD_i18n.lua(--ctld.i18n_lang = "XX"), and select it there when you want it active. -
Load the dictionary in the tests that exercise all languages —
tests/ci/unit/i18n_spec.luadofiles the non-EN dictionaries explicitly (the sharedtests/ci/helpers/loader.lualoads onlyCTLD_i18n_en.lua). -
Run
generate_i18n_dicts.ps1to confirm the new dictionary is complete, then rebuild.
Mission makers can override any string without touching a dictionary file, from
CTLD_userConfig.lua (the .miz-side config — ctld.i18n_overrides is never set inside
src/):
-- In CTLD_userConfig.lua, not in src/
ctld.i18n_overrides = {
fr = { ["Troops loaded"] = "Soldats embarqués" },
en = { ["CTLD Commands"] = "Helicopter Commands" },
}Overrides are applied once at startup, in memory, by CTLDi18n:_init() — the first call to
CTLDi18n.getInstance() iterates ctld.i18n_overrides and overwrites the matching dictionary
entries. The runtime sequence:
1. CTLD.lua loads → dictionaries populated in ctld.i18n[lang]
2. CTLD_userConfig.lua → sets ctld.i18n_overrides
3. getInstance() → _init() copies overrides into the in-memory dicts
4. ctld.tr(key) → returns the overridden value
This is a runtime mechanism and is orthogonal to generate_i18n_dicts.ps1, which is a
build-time tool operating on the dictionary source files. Overrides never touch src/.
Three functions detect gaps between EN and a target language. The audit pair returns structured
data (suitable for busted assertions and DO SCRIPT triggers); i18n_check is the legacy variant
that logs straight to env.*.
Compares one language against EN and returns a result table (plus an error string when the language is unknown):
local result, err = ctld.i18n_audit("fr")
if err then
-- language not loaded
else
if not result.version_match then
-- EN was bumped; FR may need review
end
-- result.en_version : EN's translation_version
-- result.lang_version : target's translation_version
-- result.missing : keys present in EN but absent in FR
-- result.untranslated : keys where the FR value equals the EN value
endRuns ctld.i18n_audit() on every loaded non-EN language and returns a table keyed by language
code:
local results = ctld.i18n_auditAll() -- results["fr"], results["es"], results["ko"]
for lang, r in pairs(results) do
print(lang, "#missing=" .. #r.missing, "#untranslated=" .. #r.untranslated)
endLogs missing keys as env.error and untranslated keys as env.warning, writing directly to the
DCS log. Because it does not return data it is unsuitable for assertions — use ctld.i18n_audit()
in tests and scripts. A ready-made per-language gap report you can paste into a DO SCRIPT trigger
sits in the comment block at the bottom of src/CTLD_i18n.lua.
See Building & testing for how the i18n specs run under busted, and
Architecture for the merge order that assembles the dictionaries into
CTLD.lua.