This is a cheat sheet for the cmnLoc extension by YellowAfterlife!

The extension can be acquired from itch.io.

Check out the blog post for a slightly more in-depth explanation of concepts and ideas.

Click on sections to expand/collapse them.
Quick display controls: Categories · Sections · Everything ·

General concepts
  • Each localization is represented by a "language" reference (cmn_loc_lang).
  • Language contains named groups (e.g. main menu, pause menu).
  • Groups contain key - string pairs.
  • "Getter" functions accept a "default language" (e.g. English) string that will be used if the current language does not contain the requested one.
  • The tool scans your project for uses of the functions and forms file(s) to be localized / uploaded to a localization service.
Getting started / checklist
  1. Import the extension to your project.
  2. Change the strings that you want to be translatable to go through the localization functions instead.
  3. Generate the files to be translated
  4. Translate the files!
  5. Add the translated files to your Included Files and load them when appropriate.
Localization functions
Localization functions

You can use these to get the translated strings (or source code strings if there is no translation).

Some rules apply to using these in a way that will allow them to be picked up by the generator.

cmn_loc_get_group(group_name)​

This function looks up a group in the current language by name.

If there is not a group, an empty one will be created, so the returned value is always a valid group.

cmn_loc_string(group, key, ?value, ...arguments)​

This function looks up a string in a group of the current language.

If the string exists, it will replace the value argument.


group can be a group name (for one-off uses) or a reference to a group (acquired from cmn_loc_get_group).


value can be omitted if it's the same as key:

label = cmn_loc_string("Menu", "Start"); // value is "Start" here

If one or more trailing arguments are provided, value is assumed to contain placeholders ({0}, {1}, ...) like the modern-day string function has it:

var g = cmn_loc_get_group("Settings");
label = cmn_loc_string(g, "Volume", "Volume: {0}%", 65); // stores "Volume: 65%"

If you are using GameMaker Studio 2.3 or GM2022+, you can also call the get function from a group reference, like so:

var g = cmn_loc_get_group("Settings");
label = g.get("Volume", "Volume: {0}%", 65); // stores "Volume: 65%"

For further convenience, see cmn_loc_get_func.

cmn_loc_string_ext(group, key, value, arg_array, ?count)​

This function works like cmn_loc_string, but accepts values to be inserted as an array instead.


count indicates the number of items in arg_array to use for replacements.

If omitted, the whole array will be used.

var vals;
vals[0] = 5;
vals[1] = 7;
var g = cmn_loc_get_group("UI");
label = cmn_loc_string_ext(g, "HP", "HP: {0}/{1}", vals); // stores "HP: 5/7"

If you are using GameMaker Studio 2.3 or GM2022+, you can also call the get_ext function from a group reference, like so:

var vals = [5, 7];
var g = cmn_loc_get_group("UI");
label = g.get_ext("HP", "HP: {0}/{1}", vals); // stores "HP: 5/7"
cmn_loc_get_func(group)​

If you are using GameMaker Studio 2.3 or GM2022+, this convenience function looks up a group and returns its bound get function, which further reduces the amount of added code per translatable string.

var f = cmn_loc_get_func("Settings");
label = f("Volume", "Volume: {0}%", 65); // stores "Volume: 65%"
label_back = f("Back");
Indexing rules

The generator can pick up groups and strings in your code if you follow a few rules.

  1. Groups and functions should be stored in local variables on declaration.

    var g = cmn_loc_get_group("Settings"); // yes
    global.g = cmn_loc_get_group("Settings"); // no
    var g; ...; g = cmn_loc_get_group("Settings"); // no
    

    A group is tied to its language so it's a good idea to only retrieve groups when you need strings from them in general.

  2. Group/function should be the first variable in the declaration.

    var g = cmn_loc_get_group("Settings"); // yes
    var i = 1, g = cmn_loc_get_group("Settings"); // no
    

    Fortunately, var a, b and var a; var b offer the same end result in GML.

  3. Macros are supported

    #macro LF cmn_loc_get_func
    #macro LC_SETTINGS "Settings"
    var L = LF(LC_SETTINGS);
    label = L("Volume", "Volume: {0}%", 65);
    
  4. Group names, keys, and values should be string literals or combinations of string literals (including macros).

    var g = cmn_loc_get_group("Settings.Audio"); // yes
    var g = cmn_loc_get_group("Settings" + ".Audio"); // yes
    #macro LC_SETTINGS "Settings"
    var g = cmn_loc_get_group(LC_SETTINGS + ".Audio"); // yes
    
    var g = cmn_loc_get_group(global.settings_name); // no
    var g = cmn_loc_get_group("Settings" + string(2)); // no
    var s = "Settings"; var g = cmn_loc_get_group(s + ".Audio"); // no
    

You can still use the extension functions without respecting these rules - this only affects the strings that will be automatically put in the generated files.

Generating files to translate
Generating files to translate

Once you have added localization functions to your code, it is time to generate files to be translated!

You have two options here:

  • A web-based generator
    Open cmnLocGen.html in any semi-recent web browser.

    Accepts ZIP and YYZ archives with project files inside and outputs single files/ZIP archives with translatable files in chosen format.

    Although it is a web page, it works without an internet connection and your files never leave your computer.

  • cmnLocGen.n
    This is a command-line utility for advanced users.

    You'll want to install Neko VM and run it from Command Prompt/terminal as

    neko cmnLocGen.n [arguments]
    

    Running without arguments (or with --help) will display the supported arguments.

Supported formats
Supported formats

For purposes of comparing the formats, suppose we have a snippet of code like this:

var m = cmn_loc_get_group("Menu");
cmn_loc_string(m, "Start");
cmn_loc_string(m, "About", "A good\nvideogame"); /// @loc Shown in a corner
cmn_loc_string(m, "Exit");

var o = cmn_loc_get_group("Settings");
cmn_loc_string(o, "Volume", "Volume: {0}%", 65);
cmn_loc_string(o, "Back");
CSV
CSV

A standard Comma-Separated Values table.

Default

CSV specification supports multi-line values, so the "table" structure is just about what you might expect:

GroupKeyContextText
MenuStartStart
MenuAboutShown in a cornerA good
videogame
MenuExitExit
SettingsVolumeVolume: {0}%
SettingsBackBack
This is perhaps the easiest ordered format to parse.

Alternative

This is like the regular CSV, but instead of having a mostly-redundant Group column, groups are indicated by an empty Key cell with group name placed in Context column.

KeyContextText
Menu
StartStart
AboutShown in a cornerA good
videogame
ExitExit
Settings
VolumeVolume: {0}%
BackBack
This is convenient if you intend to store your translations in a spreadsheet (as a rule can be made to highlight rows with an empty first cell differently), and less convenient for parsing.

INI
INI

Here's the deal: there is no standard for INI files and no agreed upon way of representing multi-line text in values.

Therefore I'm offering two separate options for generating INI files.

Multi-value

Multi-line values (if any) will become additional key-value pairs:

[Menu]
Start=Start
# Shown in a corner
About=A good
About=videogame
Exit=Exit

[Settings]
Volume=Volume: {0}%
Back=Back

Quotation marks will be inserted around the individual line-values if they have leading/trailing whitespace, start with a quotation mark, or contain comment characters (;, #).

Otherwise the values are assumed to have no escape characters.

Escape characters

This is a different approach to the problem: we'll assume INI strings to support JSON-style escape characters, which might sound familiar.

[Menu]
Start=Start
# Shown in a corner
About="A good\nvideogame"
Exit=Exit

[Settings]
Volume=Volume: {0}%
Back=Back
JSON
JSON

The output is about what you'd expect, group ➜ key ➜ value:

{
    "Menu": {
        "Start": "Start",
        "About": "A good\nvideogame",
        "Exit": "Exit"
    },
    "Settings": {
        "Volume": "Volume: {0}%",
        "Back": "Back"
    }
}

There's a little caveat though: there's no context information because JSON standard does not include comments.

JSON (CRX)

This is a subset of Chrome extension localization format, which is most notable for supporting multi-line descriptions.

Menu.json:

{
    "Start": {
        "message": "Start",
        "description": ""
    },
    "About": {
        "message": "a good\nvideogame",
        "description": "Shown in a corner"
    },
    "Exit": {
        "message": "Exit",
        "description": ""
    }
}

Settings.json:

{
    "Volume": {
        "message": "Volume: {0}%",
        "description": ""
    },
    "Back": {
        "message": "Back",
        "description": ""
    }
}

Translating files
Translating files
Crowdin

Disclosure:
There's quite a number of localization platforms!

I'm using Crowdin as an example here as it has the following qualities:

  • Supports groups
    Localization looks less intimidating when it's not a single massive list of key-value pairs.
  • Supports "context"/comments, including multi-line ones
  • Reasonable pricing for indie games?
    (free if your localizations are public and there aren't too many words/languages)

There might be something even better that I haven't seen.

Anyway,

  1. Create a project as usual
  2. Generate files in "JSON (CRX)" format and add them to Crowdin.
    (on Sources tab, https://crowdin.com/project/YourProject/sources/files)
  3. Add the JSON String Exporter integration to the project.
  4. Navigate to Translations (https://crowdin.com/project/YourProject/translations)
  5. Click Target File Bundles ➜ Add Bundle

    1. Set "Source files path" to *.json
    2. Set "Target file" ➜ "Format" to "JSON String Exporter"
    3. Click on the gear button next to "JSON String Exporter"

      1. Set "Configuration type" to "Object key-value"
      2. Set "Export pattern" to

        {
            "%identifier%": "%translation%"
        }
        
    4. Set "Resulting file pattern" to

      %language%/%file_name%.json
      

Once you've done some translations, you can download the bundle that you've set up earlier on the Translations page, extract the ZIP to your Included files, and load it using cmn_loc_lang_load_json_folder.

If you'd like to pack each language into a single file instead of a folder, I have added an option for that:

neko CmnLocGen.n --combine-crx crowdin-bundle.zip outputFolder

which will create a single JSON file per language in outputFolder.

Using Git for updates

If you choose an INI-based format for your files, you can translate them as usual and use version control software to help you update them (e.g. if you add/remove/change strings) later:

  1. Create a new empty local Git repository.
  2. Commit your original (pre-update) generated localization file into the main branch.
  3. Create a secondary branch.
  4. Commit the translated file into it, overwriting the original file.
    Commit changes for the file now indicate the lines that have been localized.
  5. Commit the new (post-update) generated localization file into the main branch.
  6. Merge the main branch into your secondary branch.
    Line additions/removals will happen automatically while merge conflicts will indicate text that has changed since being translated.
Loading translations
Loading translations

The following functions enable you to load localizations from files and strings.

The usual approach is to load localizations on game start or when you first need them, but if you intend to unload/reload them later, you will need to clean up your language references using cmn_loc_lang_destroy.

cmn_loc_current

This global variable holds the current language.

Changing languages is a matter of assigning a new language reference (cmn_loc_lang) into it, which will affect subsequent calls to localization functions.

JSON/maps
JSON/maps
cmn_loc_lang_load_json_file(jsonPath)​

Loads a language from a JSON file containing group ➜ key ➜ value associations.

That means an object of objects.

Returns a language reference (cmn_loc_lang).
If the file does not contain valid JSON, returns undefined.

For example, if in your Included Files you've had a file called Ukrainian.json containing the following:

{
    "Menu.Main": {
        "Multiplayer": "Мережева гра",
        "Singleplayer": "Одиночна гра",
        "Options": "Налаштування",
        "Exit game": "Вийти з гри"
    },
    "Menu.Options": {
        "SoundVolume": "Гучність звуків: {0}",
        "MusicVolume": "Гучність музики: {0}",
        "Back": "Назад"
    }
}

You could do

cmn_loc_current = cmn_loc_lang_load_json_file("Ukrainian.json");

to load it.

cmn_loc_lang_load_json_string(jsonString)​

Loads a language from a JSON string, using the same format as cmn_loc_lang_load_json_file.

If the string does not contain valid JSON, returns undefined.

cmn_loc_load_map(ds_map, takeOwnership)​

This function loads a language from a ds_map.

The map should have a group ➜ key ➜ value structure like shown in cmn_loc_lang_load_json_file.

If takeOwnership is true, the map will be associated with the language and destroyed along with it.
Note: if you are creating the map yourself (rather than decoding it), make sure to add sub-maps using ds_map_add_map so that they are associated with the parent map.


cmn_loc_lang_load_json_folder(folderPath)​

Loads a language from a folder of .json files.

Files containing invalid JSON will be ignored.

The file name (without extension) will be used for the group name and the contents will be used for key-value pairs within.

For example, if you have a folder called Ukrainian containing

  • Menu.Main.json

    {
        "Multiplayer": "Мережева гра",
        "Singleplayer": "Одиночна гра",
        "Options": "Налаштування",
        "Exit game": "Вийти з гри"
    }
    
  • Menu.Options.json

    {
        "SoundVolume": "Гучність звуків: {0}",
        "MusicVolume": "Гучність музики: {0}",
        "Back": "Назад"
    }
    

You can do

cmn_loc_lang_load_json_folder("Ukrainian");

To achieve the same result as with the cmn_loc_lang_load_json_file example above.

INI
CSV
CSV
cmn_loc_load_grid(grid, ?columns)​

Loads one or more translations from a ds_grid.

Columns (optional) is an array of column names (case-sensitive!) or indexes (0-based, includes prefix columns like Group/Key/Context) to load.
Missing names and out-of-bounds indexes are ignored.
If omitted, all available languages will be loaded.

Returns an array of language references.
If you have specified columns to load and none were found, this can be an empty array.

Configuration:

cmn_loc_csv_context_column = true

Indicates whether your CSV table contains a "context/comment" column after the "Key" column.

false:

GroupKeyValue...
MenuStartStart
MenuAboutA good
videogame
true:
GroupKeyContextValue...
MenuStartStart
MenuAboutShown in a cornerA good
videogame

cmn_loc_csv_group_in_context = false

Indicates whether the group is stored inside the "context/comment" column, as per CSV Alternative format.

false:

GroupKeyContextText
MenuStartStart
MenuAboutShown in a cornerA good
videogame
true:
KeyContextText
Menu
StartStart
AboutShown in a cornerA good
videogame

cmn_loc_csv_group_suffix = ""

If the group names end with this string, it will be trimmed off.

For example, when downloading "Preview translations" CSV from Crowdin, group names include the source file extensions ("Menu.json"), so you would want to do

cmn_loc_csv_group_suffix = ".json";
cmn_loc_lang
cmn_loc_lang

Represents a language. You can keep multiple of these in the memory at the same time,

new cmn_loc_lang(?name)

Creates a new localization language.

Unless you're going to populate key-value pairs yourself, you should look into loading functions instead.

destroy()

Localizations are typically loaded on game start and stick around for the duration of the game, but if you do need to swap them out, call this function to free the data structures related to this language.


get_group(group_name)

Returns a group with the given name; creates and stores a new one if necessary.

This is similarly handy if you're populating the key-value pairs yourself.

add_group(group)​

Adds an existing group to this language.

Returns whether successful. Fails if there's already a same-named group added.


set_plural_rules(rules_string)
cmn_loc_lang_set_default_plural_rules(rules_string)
cmn_loc_group
cmn_loc_group

Represents a string group within a language.

new cmn_loc_group(name, ?key_value_map)

Creates a new group.

key_value_map is a ds_map containing key-value pairs for this group.
If provided, an existing one will be referenced.
Otherwise a new map is created (and destroyed along with the group later).

destroy()

Destroys a previously created group along with its key-value map (if owned by it)

A language will destroy its groups when it is destroys, so you shouldn't usually have to call this yourself.


get(key, ?value, ...arguments)​
get_ext(key, value, arg_array, ?count)​
set(key, value)

Stores/replaces a key-value pair in this group.

Advanced topics
Advanced topics
ICU messages
ICU messages

The extension supports a subset of International Components for Unicode message format.

To use these, replace a simple {0} placeholder by {0, type, ...arguments}.

The following are supported:

plural

This chooses a plural form for the provided quantity.

The syntax is as following:

{index, plural,
category {text}
}

Placeholder value at index should be a number;

The message can contain one or more categories and should generally contain an other category (which acts as a catch-all if nothing else matches).

For example, you might have the following

menu_skip(L("EntryCount", "{0} public {0, plural,\n"
    + "one {game}\n"
    + "other {games}\n"
+ "}:", n));

to have "0 games", "1 game", "2 games" in English, and a translation could use the categories that are available for the language, for example:

{0} онлайн {0, plural,
one {гра}
few {гри}
other {ігор}
}:

which would produce "1 гра", "2 гри", ..., "5 ігор" as the Ukrainian pluralization rules have it.


# can be used inside the messages to insert the input quantity, like so:

menu_skip(L("EntryCount", "{0, plural,\n"
    + "one {# game}\n"
    + "other {# games}\n"
+ "}:", n));

If necessary, both the base language and translations can also use =number for special cases, like so:

menu_skip(L("EntryCount", "{0, plural,\n"
    + "one {# game}\n"
    + "other {# games}\n"
    + "=0 {no games}\n"
+ "}:", n));

Category rules are set up by calling set_plural_rules and cmn_loc_lang_set_default_plural_rules.

select

Outputs one or other string based on what the argument currently is:

{index, select,
value1 {text 1}
value2 {text 2}
other {catch-all}
}

This is typically used to pick appropriate gender-based inflections for the language, but you can use it for whatever else that might need one-of-N options depending on the language.

Plural rule syntax
Plural rule syntax

Using set_plural_rules and cmn_loc_lang_set_default_plural_rules you can specify rules for pluralization categories that you will then be able to use in plural type messages.

Per-language rules can be looked up on the Unicode website.

Expressions
Expressions
#

This is the numeric value that you're matching.

For example, a rule checking that the value is 1 might look like

one: # == 1
Numbers

Integer (4, -4) and floating-point (4.1, -4.1) numbers are supported.

Parentheses

You may enclose expressions in parentheses.

For example,

  • 3 + 1 * 2 would be 5
  • (3 + 1) * 2 would be 8
Arithmetic
  • -a
  • a + b
  • a - b
  • a * b
  • a / b
  • a div b: integer division (14 div 5 is 2)
  • a % b: remainder of division, can be fractional (alt: a mod b)
Checks

Comparisons:

  • a = b: equals (alt: a == b)
  • a != b: not equals (alt: a <> b)
  • a < b
  • a <= b
  • a > b
  • a >= b

The result of comparisons and boolean operations is 1 (true) or 0 (false).


Boolean operators:

  • a and b: boolean AND (alt: a && b)
  • a or b: boolean OR (alt: a || b)
  • not a: boolean NOT (alt: !a)

A value is considered "truthy" if it's larger than 0.5.


Range checks:

  • val in (min .. max)
  • val not in [min .. max] (alt: val !in [min .. max])

Start/end of the range can be parentheses (exclusive) or square brackets (inclusive).

For example,

  • 5 in [3, 7] is 1 (in range)
  • 1 in [3, 7] is 0 (out of range)
  • 7 in [3, 7] is 1 (edge of inclusive range)
  • 7 in [3, 7) is 0 (edge of exclusive range)

Set checks:

  • val in {a, b, c}
  • val not in {a, b, c}

Set items can be values or ranges:

  • # in {4, 5, 6} returns 1 if value is 4, 5, or 6
  • # in {4, [5, 6]} returns 1 if value is 4 or between 5 and 6 (incl.)
Functions
Functions

The following few are supported:

is_int(val)

Returns whether a number is an integer (no fractional part)

  • is_int(4) is 1
  • is_int(4.1) is 0
abs(val)

Returns the absolute value of a number

  • abs(-3) is 3
  • abs(4) is 4
sign(val)

Returns the sign of a number

  • sign(4) is 1
  • sign(-4) is -1
  • sign(0) is 0
min(a, b)

Returns the smaller of two numbers

  • min(3, 4) is 3
  • min(5, 4) is 4
max(a, b)

Returns the larger of two numbers

  • max(3, 4) is 4
  • max(5, 4) is 5
Examples
Examples
Intro
Simple

According to the sheet, in English you say "1 day" and any other number is "N days". Rather convenient, isn't it

So that could be written as:

one: # == 1
Conditions

Ukrainian (and some other languages from eastern-European group) doesn't have it so easy - the rules for the singular category are:

v = 0 and
i % 10 = 1 and
i % 100 != 11
Which translates to:

  • The number is an integer
  • Remainder of division of the number by 10 is 1
  • Remainder of division of the number by 100 is not 11

That means that the rule applies to 1, 21, 31, or 101, but not 11 or 111.

This could be written as:

one: is_int(#) and # % 10 = 1 and # % 100 != 11
Ranges

The cardinal rule for Polish is listed as following:

v = 0 and
i % 10 = 2..4 and
i % 100 != 12..14
Which translates to:

  • The number is an integer
  • Remainder of division of the number by 10 is 2..4 (incl.)
  • Remainder of division of the number by 100 is not 12..14 (incl.)

This could be written as:

few: is_int(#) and # % 10 in [2 .. 4] and # % 10 not in [12 .. 14]
Operator priorities

TLDR: it's like GML priorities, but with in / not in sandwiched between comparisons and boolean AND/OR.

From highest to lowest:

  1. Unary operators (not val, -val)
  2. *, /, %, div
  3. +, -
  4. ==, !=, <, <=, >, >=
  5. in, not in
  6. and
  7. or