Internationalization
UltiTools provides an easy-to-use API for internationalization, allowing you to easily add multilingual support to your plugin.
Create a language file
Create a lang folder in the resources folder. Put your plugin language files in it according to your needs.
{
"test": "测试",
"test2": "测试2"
}Name the file zh.json, where zh is the language code.
Language codes can be found here.
YAML Language Files
A .yml/.yaml catalogue is also supported
As of v6.3.0, a module's lang folder can also use .yml/.yaml instead of .json -- Language.fromYaml flattens nested keys with dots, and a section node is not itself turned into a string entry.
A module shipping only .yml can still fail to load its own dictionary
Every internal module shares one classloader with the core UltiTools plugin, so a lookup for lang/<code>.json inside your jar can resolve the core's own .json file before .yml is ever tried, leaving i18n(...) returning raw keys (issue #412) -- ship a lang/<code>.json alongside your .yml (even a near-duplicate) as a workaround until this is resolved.
Provenance-based file refresh
As of v6.3.0, an untouched language file is refreshed automatically
saveResources() records the SHA-256 of every lang/<code><ext> file it extracts. On the next start, a file whose recorded hash still matches its on-disk bytes is replaced by the current jar's copy, with one INFO line naming the file.
A file edited in place since it was recorded is restored as well, as of v6.3.0 (see Official files and custom language files below). (A file with no recorded hash at all is a different case; see Upgrading to 6.3.0 below.) Only when that restore cannot be written -- the file is read-only or a symbolic link -- is the edited file kept, and then only individual keys that provably lost a placeholder are resolved from the jar instead, each logged once as a WARN naming the module, file and key -- the other keys keep the edited wording. Two placeholder dialects are checked, since a module catalogue can use either:
String.formatplaceholders (%s,%d, or an explicit%1$sindex) -- your key is overridden when its placeholder count differs from the jar's key, in either direction.{0}/{PLAYER}-style brace tokens (as of v6.3.0) -- substituted with plainString#replace, notFormatter. Your key is overridden only when the jar's value contains a token yours does not have at all; a brace token you add of your own, or any rewording around an existing token, is never touched. This check is deliberately one-directional: it flags only a slot that disappeared, never a customisation you made yourself (issue #524).
To force a refresh of a customised file, delete it and restart the module: the framework re-extracts it from the jar and records a fresh baseline hash. As of v6.3.0 the module chooses its catalogue only after that extraction, so the fresh file is the one used on that start, even when an older lang/<code>.yml is still beside it (issue #540).
The provenance decision is written only after the framework has accepted the module. A copy it refuses at load (an older copy of a module already loaded, or one built for a newer framework) refreshes, replaces and records nothing (issue #460).
Upgrading to 6.3.0: files without a record
Unrecorded language files are replaced on the first 6.3.0 start, edited ones included
Files extracted before 6.3.0 have no recorded hash, so the framework cannot tell your edits from an older jar's wording. On the first start after upgrading, each such file whose bytes differ from the new jar's copy is replaced by that copy, and your old file is kept as a backup. The maintainer accepted on 2026-09-29 that this replaces edited files too.
What happens to each lang/<code><ext> file with no recorded hash (issue #459):
- Differs from the jar's copy: replaced by the jar's copy and recorded. The previous file is kept in the same
lang/folder as<file>.bak(for examplelang/en.json.bak); if that name is taken,<file>.1.bak,<file>.2.bakand so on. An existing file is never overwritten, and no backup name ends in.json,.ymlor.yaml, so a backup is never loaded as a catalogue. One WARNING per file names the file and its backup. - Byte-identical to the jar's copy: only recorded. No backup, no log line.
- Cannot be replaced (a read-only or symbolic-link file, a folder that is not writable, a record that cannot be written): left in place, nothing recorded, no backup left behind; the placeholder checks above still apply.
A second start changes nothing. Do not rename the backup back: as of v6.3.0 an official file is restored at every start, so it would be replaced again. To keep the edit, copy the backup to a custom language file instead (see the next section). No list of earlier releases' files is used to spare edited files.
A literal % must be written %%
Catalogue values are passed through java.util.Formatter. A bare % before s, d, or a digit is a real conversion — String.format("Save 90%discount", 5) returns "Save 905iscount", and the same call with no argument throws MissingFormatArgumentException. Write a literal percent as %%.
A brace token you add yourself is never flagged
The {0}/{PLAYER} check only compares FROM the bundled value TO yours -- a {note}-shaped aside you add in your own translation is never mistaken for a lost placeholder, however you word it.
Official files and custom language files
Official language files belong to UltiTools
As of v6.3.0, edit a copy, never the official file: an official language file edited in place is restored at every start.
The official language files are the lang/* files the framework and each module write: the framework's own en.json and zh.json in plugins/UltiTools/lang/ (written there as of v6.3.0), and each module's in plugins/UltiTools/pluginConfig/<module>/lang/. At every start, an official file whose bytes differ from the bundled version is restored to it, the previous file is kept as <file>.bak (named as above), and one WARNING names both and tells the operator to customise as below instead. /ul reload does the same for the framework's two files and for each module's file of the language in use. A file an earlier release wrote and nobody edited is brought up to date without a backup; a read-only or symbolic-link file is left as it is, with a line saying so (issue #608).
To customise messages, an operator:
- copies an official file, in the folder whose messages should change, under a new name that starts with its language code and a hyphen, keeping the extension --
zh.jsontozh-myserver.json; - edits the copy;
- sets
language: zh-myserverinplugins/UltiTools/config.ymland restarts or runs/ul reload.
That one setting selects the language for the framework and every module; there is no per-module setting. Every message the copy does not contain comes from the official language its name starts with (zh for zh-myserver; the longest shipped code wins, so zh-CN-myserver uses zh-CN when a module ships it), so a copy may keep only the entries it changes, and a module with no zh-myserver file simply uses its official zh. A name that starts with no shipped code, such as myserver, uses English for what it lacks and logs one warning, and a name containing any character other than ASCII letters, digits, _ and - is never used as a file name. A custom value whose %s/%d count differs from the official base's bundled value, or which lacks a {TOKEN} that value has (tokens of your own are kept), uses the bundled value for that key, in memory, with one warning naming the file and the key -- the same guard an edited official file used to get -- and the file itself is not changed. Choose a name no module ships, such as zh-myserver: a module that does not ship the configured official code treats it as a custom name, and a code a later release starts shipping turns that file into an official one. The custom file is never written, replaced, backed up or recorded by any start, reload, upgrade or module update.
For module authors: getLanguageCode() and getConfiguredLanguage()
i18n(...) already resolves the custom file first and the official language beneath it, so a module needs no code for custom language files. Behaviour that depends on the language -- which shipped text to write into a configuration file, which example content to create -- should use getLanguageCode(), which as of v6.3.0 returns the official language the messages are based on: zh under language: zh-myserver, and the fallback the module actually uses for a name it does not ship. The configured name itself is getConfiguredLanguage(), new in v6.3.0. Before v6.3.0 getLanguageCode() returned the configured name, which is the same value for every official code.
String code = getLanguageCode(); // "zh" under language: zh-myserver
String configured = getConfiguredLanguage(); // "zh-myserver"Ship each language as lang/<code>.<ext>: those codes are the official languages a custom name can be based on.
Register language
The active language comes from the UltiTools config
The language actually used is the language key of the UltiTools main plugin config, which getConfiguredLanguage() returns; getLanguageCode() returns the official language it is based on. As of v6.3.0, supported() is now consulted before that language is constructed: an unsupported configured code produces a warning and the module falls back to a language that actually exists, instead of silently loading an empty dictionary.
supported()'s default implementation is derived from the module's own lang/*.json files, so name each language file lang/<code>.json and it does not need overriding at all. Overriding it still works and takes priority, for the rare case where a module wants to claim support for a code it has no file for.
UltiTools needs to know which languages your plugin supports, so you need to register them in the class that inherits UltiToolsPlugin.
A simple way is to add @I18n and add language codes in the class that inherits UltiToolsPlugin:
@I18n({"zh", "en"})Sure, you can also override the supported() method and return a List<String> containing the language code:
@Override
public List<String> supported() {
return Arrays.asList("zh", "en");
}How to use
In the class that inherits UltiToolsPlugin, there is an i18n method for getting multilingual strings.
String test = i18n("test");
// Output:测试If the string does not exist in the language file, the string itself will be returned.
String test3 = i18n("test3");
// Output:test3TIP
In the case of only two languages, you can create only one language file, where the key of the key-value pair is the original text and the value is the translation.
The framework's own messages
As of v6.3.0, the text UltiTools itself shows follows the language key of the main plugin config, through the catalogue convention described above: the key is the Chinese source text, lang/zh.json maps it to itself, and lang/en.json gives the English. This covers the reply every command sender gets when a module's command body throws (Command execution failed: <reason>), the default processing notice of an @AsyncCommand (Processing...), the framework's console lines, and the lines it streams to the panel's live log. Before v6.3.0 these were always Chinese; under language: zh the text is unchanged. As of v6.3.0 the framework's official catalogues are also written to plugins/UltiTools/lang/, and a custom language name selects a copy there exactly as for modules (see Official files and custom language files); the official texts are read as UTF-8 on every platform.
The verification e-mail the framework sends stays bilingual, with each Chinese line paired with its English line, because its recipient's language is not the server's.