Skip to content
QinShenYu edited this page Jun 16, 2026 · 1 revision

Namespace:​ ScavLib.i18n

Warn

i18n translation is still in the testing phase and may have issues such as being unable to translate or only translating certain parts. If you encounter this situation, you can open an issue in the DLL repository.


ScavLib's localization layer overlays mod-supplied translations on top of the game's Locale system. Every custom string flows through the vanilla Locale.GetItem / GetBuilding / GetMoodle / GetOther lookups, so the game's fallback chain (current language → EN → bare id) is preserved.

The system is JSON-driven: ship one or more JSON files in your mod's lang/ folder and call LocaleManager.AutoRegister once during Awake(). There is also a programmatic API for ad-hoc strings, which is what CustomItemBuilder.DisplayName(...) / .Description(...) use under the hood.

Replaces the older ScavLib.locale.LocaleRegistry. See Migrating from LocaleRegistry at the bottom of this page for a side-by-side conversion table.


Quick Start

1. Drop translation files next to your DLL

BepInEx/plugins/
  YourMod.dll
  lang/
    EN.json
    zh-CN.json

2. Register them in Awake()

using ScavLib.i18n;
using System.IO;

private void Awake()
{
    string pluginDir = Path.GetDirectoryName(Info.Location);
    LocaleManager.AutoRegister("YourMod", pluginDir);
}

AutoRegister loads EN.json first, then overlays the file matching the active game language. With the ScavLib runtime hooks in place, switching language at runtime via Locale.ChangeLanguage(...) automatically re-syncs every registered custom item's fullName from the JSON — no manual refresh required.

3. Reference the same id from your item

CustomItemBuilder.Create("yourmod_salve", "YourMod", ItemTemplate.Bandage)
    .Category("medical")
    .Register(out _);

With EN.json containing "items": { "yourmod_salve": "Herbal Salve" }, the item's display name resolves correctly through Locale.GetItem from that point on.


Language Resolution

LocaleManager.AutoRegister uses the game's active language code, read from Locale.currentLangName (falling back to PlayerPrefs.GetString("locale", "EN")). Resolution order:

  1. EN.json — always loaded if present, used as the base layer.
  2. <currentLang>.json — overlays on top of EN.
  3. If <currentLang> contains a region (e.g. zh-CN) and no exact match is found, ScavLib tries the short code (zh.json).

File name matching is case-insensitive, so EN.json, en.json, and En.json all resolve to the EN layer.

You can call LocaleManager.GetGameLanguageCode() to inspect the resolved code yourself.


JSON Schema

A localization file is a single JSON object whose top-level keys map to the corresponding Locale.currentLang dictionaries. Every section is optional — include only what you need.

{
  "items": {
    "yourmod_salve": "Herbal Salve",
    "yourmod_pistol": "Service Pistol"
  },

  "buildings": {
    "yourmod_workbench": "Field Workbench"
  },

  "moodles": {
    "yourmod_focused": "Focused"
  },

  "other": {
    "yourmod_salve_desc": "A pungent paste that soothes burns and shallow cuts.",
    "yourmod_ui.confirm": "Confirm",
    "yourmod_ui.cancel": "Cancel"
  },

  "character": [
    {
      "greeting": ["Hello, friend.", "Took you long enough."],
      "farewell": "See you out there."
    }
  ],

  "pauseQuotes": [
    "Breathe.",
    "Press on."
  ]
}
Top-level key Target dictionary Notes
items Locale.currentLang.main Item display names. Reachable via Locale.GetItem(id).
buildings Locale.currentLang.buildings Building names.
moodles Locale.currentLang.moodles Moodle names.
other Locale.currentLang.other Free-form strings. Reachable via Locale.GetOther(key).
character Locale.currentLang.character Per-character dialog. Each entry is an object whose values may be a single string or an array of strings (see below).
pauseQuotes Locale.currentLang.pauseQuotes List of pause-screen quotes. Existing quotes are preserved; new entries are appended (deduped).

Nested keys in other

Nested objects under other are flattened into dotted keys. Both shapes below produce the same lookup key yourmod_ui.confirm:

"other": {
  "yourmod_ui.confirm": "Confirm"
}
"other": {
  "yourmod_ui": {
    "confirm": "Confirm"
  }
}

Top-level objects whose key is not one of the reserved sections (items, buildings, moodles, other, character, notes, pauseQuotes, pdaNotes) are also flattened into other automatically:

{
  "yourmod_status": {
    "online":  "Online",
    "offline": "Offline"
  }
}

becomes yourmod_status.online / yourmod_status.offline in other.

character entries

Each entry in the character array corresponds to one slot in Locale.currentLang.character. Values can be either a single string or an array of strings:

"character": [
  {
    "greeting": ["Hello.", "Howdy."],
    "farewell": "Take care."
  }
]

A bare string is wrapped into a one-element list, matching the runtime's expected Dictionary<string, List<string>> shape.

pauseQuotes

pauseQuotes is treated as a list of strings; existing entries are preserved and only new ones are appended. Duplicates (by exact string match) are skipped.


Programmatic API

You can register strings directly without shipping a JSON file. This is what CustomItemBuilder.DisplayName(...) / .Description(...) use internally.

LocaleManager.RegisterItem(
    id: "yourmod_salve",
    names: new Dictionary<string, string>
    {
        { "EN",    "Herbal Salve" },
        { "zh-CN", "草药膏" }
    },
    descs: new Dictionary<string, string>
    {
        { "EN",    "Soothes burns and shallow cuts." },
        { "zh-CN", "舒缓烧伤与浅创。" }
    });

LocaleManager.RegisterString(
    key: "yourmod_ui.confirm",
    translations: new Dictionary<string, string>
    {
        { "EN",    "Confirm" },
        { "zh-CN", "确认" }
    });
Method Description
LocaleManager.RegisterItem(id, names, descs = null) Register a custom item's display name and (optional) description. Description is stored under the key <id>dsc in other.
LocaleManager.RegisterString(key, translations) Register an arbitrary other-table string (UI labels, system messages, etc.).
LocaleManager.AutoRegister(modId, modFolder) Load <modFolder>/lang/EN.json plus the active-language file.
LocaleManager.GetGameLanguageCode() The active game language code (Locale.currentLangName with a PlayerPrefs fallback).
LocaleManager.RefreshAllUI() Re-pull every active UILocalizer text from Locale.GetOther(...). Useful after manually mutating Locale.currentLang.other.
LocaleManager.RegisteredItemIds HashSet<string> of every item id that has gone through RegisterItem (or CustomItemBuilder.DisplayName).

RegisterItem and RegisterString accept an IReadOnlyDictionary<string, string> keyed by language code. Resolution at runtime tries the active code, then EN, then English, then the first available value — pragmatic enough to never leave a custom string fully blank.

Lookup priority for other table

other-table entries can come from three places:

  1. JSON files loaded via AutoRegister.
  2. Strings registered via RegisterString.
  3. Manually written entries already in Locale.currentLang.other.

When both layers define the same key, the JSON layer wins during the Locale.LoadLanguage Postfix sweep, because manual injection runs first and JSON injection runs last. If you need the programmatic value to win, write it after the language load — e.g. inside an IModLifecycle.OnEnabled() that runs after the first LoadLanguage.


When the Sync Runs

ScavLib hooks three game entry points (in LocalePatches):

Game method What ScavLib does
Locale.LoadLanguage (Postfix) Run the full JSON + manual injection; re-pull ItemInfo.fullName for every registered custom item from Locale.GetItem(id).
Locale.ChangeLanguage (Postfix) Re-pull ItemInfo.fullName for every registered custom item.
Item.SetupItems (Postfix) Re-pull ItemInfo.fullName for every registered custom item.

Net effect: switching language in-game updates custom items immediately, and reloading items (e.g. across run boundaries) keeps display names in sync.


Translation Template Generator

TranslationTemplateGenerator.ExportTemplate(path) writes a JSON template listing every item id ScavLib has seen so far, with TODO: … placeholders for the names. If the file already exists, the generator merges your existing translations in so progress is preserved:

  • Keys you already translated → kept verbatim.
  • Keys present in the running game but missing from the file → added with TODO: <id> Name.
  • Keys present in the file but no longer registered → kept (treated as a union, not a prune).
using ScavLib.i18n;

// In a debug command, menu button, or one-off Awake():
TranslationTemplateGenerator.ExportTemplate(
    Path.Combine(BepInEx.Paths.ConfigPath, "yourmod_lang_template.json"));

A typical workflow:

  1. Run the game once with your mod loaded so all items register.
  2. Trigger ExportTemplate to write a template.
  3. Translate the placeholders, save the file as EN.json / zh-CN.json etc. inside lang/.
  4. Re-running the generator on the same path keeps your translations and only fills in newly added ids.

Manual Injection (Advanced)

LocaleManager.PerformInjection() is what LocalePatches calls during Locale.LoadLanguage Postfix. You normally never need to call it yourself, but it can be invoked manually if you mutate JsonLocaleCache / programmatic registrations after the language has already loaded and want them to take effect immediately.

After the call, LocaleManager.RefreshAllUI() re-pulls every active UILocalizer text so on-screen labels reflect the new strings.


Migrating from LocaleRegistry

The old ScavLib.locale.LocaleRegistry API has been removed. The most mechanical migration is to keep your strings in code and switch to LocaleManager:

Old (ScavLib.locale) New (ScavLib.i18n)
LocaleRegistry.AddItem(id, "EN", text) LocaleManager.RegisterItem(id, new Dictionary<string,string>{{"EN", text}})
LocaleRegistry.AddItem(id, "zh", text) Same RegisterItem call with "zh" (or "zh-CN") added to the dictionary.
LocaleRegistry.AddOther(key, "EN", text) LocaleManager.RegisterString(key, new Dictionary<string,string>{{"EN", text}})
LocaleRegistry.AddItems("EN", dict) Either call RegisterItem per id, or move the entire payload into lang/EN.json under "items".
LocaleRegistry.AddOthers("EN", dict) Same — call RegisterString per key, or move into lang/EN.json under "other".

For any non-trivial amount of text, prefer the JSON path. Translators do not need to touch C# code, and the TranslationTemplateGenerator keeps the file synchronised with your registered ids over time.

CustomItemBuilder.DisplayName(...) and .Description(...) are unchanged in shape — they used to forward to LocaleRegistry, they now forward to LocaleManager. No call-site change is required.


Limitations

  • The JSON files are read at AutoRegister time. Hot-reloading a JSON file while the game is running is not supported; call AutoRegister again or restart the game.
  • LocaleManager.RegisteredItemIds only tracks ids that went through RegisterItem or CustomItemBuilder.DisplayName. JSON-only ids are applied to Locale correctly but do not show up in RegisteredItemIdsTranslationTemplateGenerator therefore lists ids that have at least one programmatic display-name registration. If you want a JSON-only id in the template output, call RegisterItem(id, names: new Dictionary<string,string>()) once at startup with an empty dictionary.
  • pauseQuotes is appended on every load. If you want to replace vanilla quotes wholesale, do it after Locale.LoadLanguage by writing to Locale.currentLang.pauseQuotes directly.

Clone this wiki locally