H073.Local
2.0.0-alpha.3
Prefix Reserved
dotnet add package H073.Local --version 2.0.0-alpha.3
NuGet\Install-Package H073.Local -Version 2.0.0-alpha.3
<PackageReference Include="H073.Local" Version="2.0.0-alpha.3" />
<PackageVersion Include="H073.Local" Version="2.0.0-alpha.3" />
<PackageReference Include="H073.Local" />
paket add H073.Local --version 2.0.0-alpha.3
#r "nuget: H073.Local, 2.0.0-alpha.3"
#:package H073.Local@2.0.0-alpha.3
#addin nuget:?package=H073.Local&version=2.0.0-alpha.3&prerelease
#tool nuget:?package=H073.Local&version=2.0.0-alpha.3&prerelease
H073.Local
On authorship. The code and the tests in this library are 100% human written. No AI was involved in writing them. The one exception is this README: it was written by AI and then reviewed by a human before release.
Show your app's text in more than one language.
You keep your text in files, one per language, and ask for a piece of it by name. The library gives back the active language, substitutes values like names and numbers, picks the correct plural form for whatever language is running, and falls back to another language when something has not been translated yet. Your C# does not change when you add a language, and it does not change when that language needs four plural forms instead of two.
Works with .NET 8 and .NET 10. No dependencies.
Install
dotnet add package H073.Local --prerelease
Status. This is prerelease software and it is not finished. Limits and known gaps lists what is still unverified — most importantly that the binary format has never been read by a second implementation, so it is not frozen yet.
Already using version 1.x? Your JSON files still work as they are. See Coming from 1.x.
Declaring what text you need
Start with a list of what your app says, before any of it is translated. That list is the
schema: one file per group of keys, under _schema.
MyGame/
└── Localization/
└── _schema/
├── ui.json
└── items.json
// Localization/_schema/ui.json
{
"keys": {
"greeting": { "context": "Shown once, on first launch" },
"menu.play": { "context": "Main menu button", "maxLength": 12 },
"welcome": { "variables": { "name": "the player's display name" } },
"files": { "forms": "plural" }
}
}
| Field | |
|---|---|
context |
What the string is for, written for whoever translates it. |
maxLength |
Longest a translation may be, in characters. |
variables |
Placeholder names the text may use. Anything else is an error. |
forms |
single, plural, gender or case — the shape the value must take. |
bank.tags |
Groups, for loading several files at once. |
bank.overrides |
This file replaces text another file declares. |
The schema is optional, and everything below works without it. What it buys is that
hxloc check can then compare every language against a declared list rather than guessing
one, and refuse a build over a key you forgot to translate — including in your source
language, which without a schema is the one thing never checked, because it defines the
answer.
It never ships. It describes your sources; a shipped catalogue is the result of applying it.
Filling in a language
A language is a folder named with its language code, holding one file per group of keys — the same names the schema uses:
Localization/
├── _schema/
│ ├── ui.json
│ └── items.json
├── en/
│ ├── ui.json ← your source language, a language like any other
│ └── items.json
├── de/
│ ├── ui.json
│ └── items.json
└── ja/
└── ui.json ← incomplete on purpose: the rest falls back
A language does not have to be complete. Japanese above has no items.json at all, and
the missing text falls back to another language rather than breaking anything.
Regional codes work: a de_AT folder is consulted before de, so an Austrian catalogue
can override a handful of strings and inherit the rest. de-AT and de_at name the same
folder.
What goes in the file
One JSON object. This is the entire format:
{
"greeting": "Hello!",
"menu": {
"play": "Play", // → key "menu.play"
"settings": {
"audio": "Audio" // → key "menu.settings.audio"
}
},
"welcome": "Welcome, {name}!", // a value gets substituted here
"gold": "{amount:N0} gold", // …and formatted in the reader's language
"files": { "$plural": { // one form per counting category
"one": "{count} file",
"other": "{count} files"
} },
"merchant": { "$gender": { // labels are yours to choose
"m": "der Händler",
"f": "die Händlerin"
} },
"city": { "$case": { // grammatical case, same idea
"nominative": "Kraków",
"genitive": "Krakowa"
} },
"untranslated": null // not done yet — falls through
}
| What you write | What it means |
|---|---|
"key": "text" |
A plain translation. |
"key": { … } |
A group; the names join with .. |
"key": { "$plural": {…} } |
Forms by counting category — one, few, many, other. |
"key": { "$gender": {…} } |
Forms by gender, labelled however you like. |
"key": { "$case": {…} } |
Forms by grammatical case, likewise. |
"key": null |
Not translated yet. |
"key": ["a","b"] |
Rejected — a list cannot say which form is which. |
Nesting is a convenience: "menu": { "play": … } and "menu.play": … mean the same
thing. Comments and trailing commas are allowed, because people edit these by hand.
Why null and not "". An empty string is a translation that happens to be blank, and
it hides the fallback. null says nobody has translated this, so the next language answers.
Loading and using it
using HxLocal;
using var loc = new Localizer("de") // the language to start in
{
Path = "Localization", // the folder your language folders sit in
Fallback = ["en"], // where to look when German has no answer
};
loc.Get("menu.play"); // "Spielen"
That is the whole setup. Keep one Localizer for your application.
Nothing there is required. Every part of it is a property with a default, so this is the same program:
using var loc = new Localizer(); // no arguments at all
loc.Path = "Localization"; // (this one is already the default)
loc.Fallback = ["en"];
loc.SetLanguage("de");
loc.Get("menu.play");
Set them whenever you like — at startup, or later when the player picks a language in a
menu. Changing one re-reads whatever is already loaded. The argument to new Localizer is
just shorthand for the starting language, so you can write it all as one expression; leave
it out and you start in English until SetLanguage says otherwise.
You do not have to say what to load. The first lookup reads whatever the folder holds. There is a way to control that — loading below — and you will want it once you have a chapter's worth of dialogue you do not need on the main menu. Until then, ignore it.
Get("menu.play") |
The text. Returns the key itself if nothing has it, so nothing crashes. |
TryGet("menu.play", out var text) |
Same, but tells you whether it was found. |
Format("welcome", new LocArg("name", "Alex")) |
Substitutes {name}, formats numbers and dates. |
Plural("files", 3) |
Picks the right counting form; supplies {count}. |
Ordinal("rank", 3) |
1st, 2nd, 3rd — a different rule set from counting. |
Variant("merchant", "f") |
Picks a gender or case form. |
SetLanguage("en") |
Switch. Raises LanguageChanged so you can redraw. |
Has("menu.play") · Keys() · Languages |
Ask what exists. |
Culture · IsRightToLeft |
For formatting and for mirroring a layout. |
loc.Get("menu.play"); // "Spielen"
loc.Format("welcome", new LocArg("name", "Alex")); // "Willkommen, Alex!"
loc.Plural("files", 3); // "3 Dateien"
loc.SetLanguage("en");
Every one of them has a Try. Get renders the key when nothing has it, which is
indistinguishable from a translation that happens to equal its key. When the difference
matters, ask:
if (loc.TryFormat("welcome", out string text, new LocArg("name", "Alex")))
label.Text = text;
TryGet, TryFormat, TryPlural, TryOrdinal and TryVariant each hand back exactly
what their plain counterpart would return, plus whether it was found. None of them throw,
and none of them file a missing-key report — you wrote Try, so you are handling it.
Loading
You can skip this section. The first lookup loads everything, and for most projects that is the right answer forever.
You want it when a file is big enough that reading it costs something you would notice — a chapter of dialogue, a DLC's worth of item names. Then you say what to load and when, and the automatic load never happens:
Load("ui") · Load("ui", "items") |
These banks. Several go in one swap. |
Load(names) |
The same, from a list you computed. |
LoadAll() |
Everything any language offers. |
Unload("chapter2") · UnloadAll() |
Drop them again, freeing what they held. |
Loaded |
What is in memory right now. |
Reload() |
Re-read it all from disk. |
WatchForChanges() |
Watch the folder and reload on change, debounced. |
Each of the four loading calls has an Async twin that reads without blocking:
LoadAsync, LoadAllAsync, and SetLanguageAsync for the language switch.
await loc.LoadAsync("chapter2"); // large file, no stutter
loc.Unload("chapter2"); // scene over
Tags are just a list of bank names. The schema can group banks, and the group turns
into names you hand to the same Load:
loc.Load(loc.Schema.BanksWithTag("hud"));
await loc.LoadAsync(loc.Schema.BanksWithTag("hud"));
There is no separate "load by tag" call, which is why the asynchronous version exists for free. A tag matching no banks loads nothing — an empty list means empty, never "everything".
A bank loaded later overrides one loaded earlier, key by key, and unloading restores what was underneath. That is how an override pack works — and section 5 covers how the schema keeps it from also being a way to shadow a key by accident.
Loading is safe while other threads read. Lookups take no lock and never see a half-applied load.
Options
There is no options object, and nothing is required. Every setting is a property with a default, and setting one re-reads whatever is loaded — so it works in an object initializer, in a constructor, or from a settings menu three screens later.
using var loc = new Localizer("de")
{
Path = "Localization",
Fallback = ["en"],
ShowFallback = FallbackDisplay.Brackets,
};
// identical, spread out
var loc = new Localizer();
loc.Fallback = ["en"];
loc.ShowFallback = FallbackDisplay.Brackets;
loc.SetLanguage("de");
The one thing that is a method rather than a property is SetLanguage, because switching
raises LanguageChanged and a UI needs that to redraw. Language itself is read-only for
the same reason: an assignment that fires events at other people is a surprise.
| Default | ||
|---|---|---|
Path |
"Localization" |
The folder holding your language folders. |
Fallback |
[] |
Tried in the order you write them. |
RegionalFallback |
true |
de_AT also consults de. |
ShowFallback |
None |
Mark text that came from a fallback, so gaps are visible while testing. |
MissingKeys |
null |
Receives keys nothing could serve. |
ThrowOnMissing |
false |
Turn a missing key into a failure. For tests. |
CultureFor |
null |
Override how a language code maps to number and date formatting. |
Source |
null |
Read from something that is not a folder. Overrides Path. |
Source is the escape hatch, and almost nobody needs it. It takes an ILocaleSource —
your own class, reading from a database, an embedded resource, a download cache. It is
also where the tuning options for a folder live, because they are properties of files
rather than of the localizer:
using HxLocal.Sources;
Source = new FileSource("Localization") { MemoryMap = true, CacheLimit = 512 },
| Default | ||
|---|---|---|
Accepts |
Any |
Json or Compiled to insist on one storage format. |
MemoryMap |
false |
Keep compiled catalogue bytes out of the managed heap. |
CacheLimit |
0 |
Cap how many decoded strings are retained. 0 means no limit. |
VerifyHash |
false |
Check each compiled file's checksum on load. |
The last three apply to compiled catalogues and are ignored for JSON — a parsed catalogue has already materialised every string, so there is nothing left for them to act on.
The building blocks
Five words, and you know the whole vocabulary.
| key | The name you look text up by: menu.play. You make these up. |
| language | A folder name: en, de, pt_br. Your source language is one of these — there is no special folder for it. |
| bank | A file name without .json: ui, items. Also yours to choose. |
| tier | The order a lookup searches: active language, then each fallback in turn, then the key itself. |
| source | Where text is read from. A folder, unless you say otherwise — set Source to read from anywhere else. |
You do not choose a storage format
Text can be stored two ways: as the JSON you edit by hand, or as a compact binary called LOC3, built from that JSON during your build. LOC3 loads 126× faster and holds 2.5× less memory.
Your code does not know which it is reading. The format is a property of the files, so
the localizer reads whatever is in the folder — a .loc3 if one is there, the .json
otherwise. Only the path differs:
Path = "Localization", // JSON
Path = "Localization.compiled", // compiled
Same call, same everything after it. Plurals, gender, ICU, banks, fallbacks, missing-key reports — all identical, because both are read through one interface. The only difference you can observe is that hot reload has nothing to watch in a compiled folder.
Most projects point at the source folder while developing, so a text change shows up live, and at the compiled folder in release builds. That is one line in your build configuration, not a different program.
You never have to compile at all. JSON is a complete answer; section 10 opens by saying you can skip it.
When the ambiguity matters, say so: Accepts = CatalogueFormat.Compiled refuses to fall
back to JSON, which catches a build step that did not run rather than quietly shipping
yesterday's text.
Source = new FileSource("Localization.compiled") { Accepts = CatalogueFormat.Compiled },
Beyond all of this sits hxloc, a command-line
tool that validates your files against the schema and compiles them. It is a separate
package, and nothing above requires it.
Contents
Start here — read these in order and you can ship a two-language app.
- Your first translated string
- Adding a second language
- Putting values into text —
{name}, numbers, dates - Counting things — plurals, one sentence with two counts, 1st / 2nd / 3rd
- Splitting your text into banks — and loading them by tag
- When a translation is missing — declaring what exists, and seeing the gaps
Going further — reach for these when you hit the problem they solve.
- Der, die, das — gender and case
- Checking your translations with hxloc
- Hot reload while you work
- Faster startup with compiled files — optional
- Advanced — hot paths, threading, custom storage, trading memory
Reference
- Every file format rule
- API reference
- Benchmarks
- Coming from 1.x
- The LOC3 binary format
- Limits and known gaps
Start here
1. Your first translated string
Everything above was the shape of it. This is the smallest version you can actually run, one step at a time — a program that prints text it read from a file. Every later section is a variation on this.
Step 1 — make a folder for your text. Anywhere in your project. Inside it, one folder per language, named with its language code.
MyApp/
└── Localization/
└── en/
└── ui.json
Step 2 — write the text. Create Localization/en/ui.json:
{
"greeting": "Hello!",
"goodbye": "See you later."
}
The names on the left (greeting, goodbye) are keys — how you will ask for each
piece of text. You make them up. The text on the right is what gets shown.
Step 3 — read it from C#.
using HxLocal;
using var loc = new Localizer();
Console.WriteLine(loc.Get("greeting")); // Hello!
Console.WriteLine(loc.Get("goodbye")); // See you later.
That is a complete, working program, and no, nothing is missing from it. Localization
is where it looks by default, English is the language it starts in, and there is no load
step — the first lookup reads what is in the folder.
You override any of that when you need to, and not before:
using var loc = new Localizer { Path = "Text" }; // different folder
loc.SetLanguage("de"); // different language
A Localizer is the one object you keep around — usually for your whole application. It
knows which language is active and holds the text that has been loaded. Everything else in
this document is a method on it.
Two things happened:
| Line | What it does |
|---|---|
new Localizer() |
Reads ./Localization, starting in English. |
loc.Get("greeting") |
Looks up the key and returns its text — reading en/ui.json on the way, since nothing was loaded yet. |
Two things you may be wondering about:
Why using var? It makes C# clean up the localizer when you are done with it. If you
keep one localizer alive for your whole app — which is normal — store it in a field
instead and call Dispose() at shutdown, or just let the process exit.
Where did ui go? Nowhere — the file is still called ui.json, and the first lookup
read it along with everything else in the folder. The name matters once your text is split
across several files and you want to load them separately. See
section 5.
Make sure the files end up next to your program
JSON files are not copied to the build output automatically. Add this to your .csproj:
<ItemGroup>
<None Include="Localization\**\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
Without it you will get your keys back instead of your text, because the folder is not there at runtime.
2. Adding a second language
This is where the work you did in section 1 pays off: adding German means adding a folder, and not one line of C# changes. It also introduces the idea that makes a half-finished translation survivable — falling back.
Add another folder with the same keys:
Localization/
├── en/
│ └── ui.json
└── de/
└── ui.json ← new
// Localization/de/ui.json
{
"greeting": "Hallo!",
"goodbye": "Bis später."
}
using var loc = new Localizer { Fallback = ["en"] }; // if German has no answer, try English
loc.SetLanguage("de");
loc.Get("greeting"); // Hallo!
loc.SetLanguage("en"); // switch at any time
loc.Get("greeting"); // Hello!
SetLanguage is how a language picker works: call it whenever the player chooses, and
subscribe to LanguageChanged to redraw. If you already know the language at startup you
can pass it straight to the constructor — new Localizer("de") — and skip the first call.
It is the same thing either way.
What "fall back" means
If German is missing a key, the library tries English before giving up. That way a half-finished translation shows readable English instead of a broken screen.
Localization/
├── en/ui.json { "greeting": "Hello!", "settings": "Settings" }
└── de/ui.json { "greeting": "Hallo!" }
↑ no German translation yet
loc.Get("greeting"); // Hallo! ← German has it
loc.Get("settings"); // Settings ← German doesn't, so English answers
You can list several fallbacks — they are tried in the order you write them:
new Localizer("pl") { Fallback = ["de", "en"] };
// ↑ ↑ ↑
// Polish then then English
// first German
If nothing has the key at all, Get returns the key itself — so you see settings on
screen. Ugly, but it never crashes and it tells you exactly which key is missing.
Regional codes fall back to their language
de_AT looks for Austrian text first and plain German next, without you listing it:
Localization/
├── de_AT/ui.json { "greeting": "Servus" } ← a handful of overrides
├── de/ui.json { "greeting": "Hallo", "bye": "Tschüss" }
└── en/ui.json
using var loc = new Localizer("de_AT") { Fallback = ["en"] };
loc.Get("greeting"); // Servus ← Austrian
loc.Get("bye"); // Tschüss ← German, without de being in the chain
The base language is inserted right after the regional one, ahead of your fallback chain,
and the same happens to regional codes in the chain. Turn it off with
RegionalFallback = false when your regional codes are genuinely unrelated
languages.
Reacting to a language change
loc.LanguageChanged += (sender, e) =>
{
Console.WriteLine($"switched from {e.PreviousLanguage} to {e.CurrentLanguage}");
RedrawEverything();
};
Your UI does not update by itself. Redraw it here.
Which languages are available?
var languages = loc.Languages; // ["de", "en", "pl"] — read from the folder names
3. Putting values into text
Never build sentences by gluing strings together:
// Don't. Word order differs between languages, and this cannot be translated.
loc.Get("welcome") + " " + playerName + "!"
Instead put a placeholder in the text and fill it in:
{ "welcome": "Welcome, {name}!" }
using HxLocal.Format;
loc.Format("welcome", new LocArg("name", "Alex")); // Welcome, Alex!
A translator can now move {name} wherever their language needs it:
Localization/
├── en/ui.json { "welcome": "Welcome, {name}!" }
├── de/ui.json { "welcome": "Willkommen, {name}!" }
└── ja/ui.json { "welcome": "{name}さん、ようこそ!" }
↑ the name comes first here
Your C# does not change.
Several values, and number formatting
{ "report": "{player} collected {amount:N0} gold" }
loc.Format("report",
new LocArg("player", "Alex"),
new LocArg("amount", 1234567));
// in English: Alex collected 1,234,567 gold
// in German: Alex collected 1.234.567 gold
The part after the colon is a standard .NET format string — N0 for a grouped whole
number, N2 for two decimals, d for a short date, and so on. Numbers are formatted
using the language you are currently in, not the language of the computer running the
app. Germans see 1.234.567, Americans see 1,234,567, automatically.
A literal curly brace
Double it:
{ "hp": "{{HP}} {current}/{max}" }
renders as {HP} 42/100.
If a placeholder has no value
It stays on screen as {name} rather than disappearing. That is on purpose: a blank space
looks like a rendering bug, while {name} tells you exactly what went wrong.
4. Counting things
This is the part people usually get wrong, so here is why it is not simply "add an s".
English has two forms: 1 file, 2 files. Japanese has one — the word never
changes. Polish has four. Arabic has six. And which form applies is not "is it 1
or not": in Polish, 2 to 4 take one form, 5 to 21 take another, 22 to 24 go back to the
first.
So instead of writing an if, you write one line per category and let the library pick:
{
"files": { "$plural": {
"one": "{count} file",
"other": "{count} files"
} }
}
loc.Plural("files", 1); // 1 file
loc.Plural("files", 7); // 7 files
{count} is filled in for you — you do not pass it.
The same key in Polish needs more forms:
{
"files": { "$plural": {
"one": "{count} plik",
"few": "{count} pliki",
"many": "{count} plików"
} }
}
loc.Plural("files", 1); // 1 plik
loc.Plural("files", 3); // 3 pliki
loc.Plural("files", 5); // 5 plików
loc.Plural("files", 22); // 22 pliki ← back to "few"
Your C# is identical in both cases. Only the JSON differs. That is the entire point.
And in Japanese, one line is enough — write it as a plain string:
{ "files": "ファイル{count}個" }
loc.Plural("files", 5); // ファイル5個
Side by side, the same key across three languages:
Localization/
├── en/ui.json "files": { "$plural": { "one": "…", "other": "…" } }
├── pl/ui.json "files": { "$plural": { "one": "…", "few": "…", "many": "…" } }
└── ja/ui.json "files": "ファイル{count}個"
The six category names
These names come from CLDR — the Unicode standard that records how each language counts.
You do not have to memorise which language uses which; hxloc check
(section 8) tells you what is missing.
| Name | Roughly |
|---|---|
other |
The catch-all. Every language has this one. Always write it. |
one |
The singular. |
zero |
Only Arabic and Latvian. |
two |
Only Arabic and Slovenian. |
few |
Slavic languages, Arabic. |
many |
Slavic languages, Arabic. |
If a form is missing, the other form is used instead. Grammatically wrong, but it still
renders — nothing crashes.
Extra values alongside the count
{ "loot": { "$plural": {
"one": "{player} found {count} {item}",
"other": "{player} found {count} {item}s"
} } }
loc.Plural("loot", 3, new LocArg("player", "Alex"), new LocArg("item", "gem"));
// Alex found 3 gems
One sentence, two counts
A single plural axis breaks down as soon as a sentence counts two things. "3 players in 1 room" needs each number to pick its own form, and splitting the sentence into fragments is exactly what a translator must not be asked to do — word order differs per language.
For that, write the whole sentence as an ICU MessageFormat pattern:
{
"report": "{players, plural, one {# player} other {# players}} in {rooms, plural, one {# room} other {# rooms}}"
}
loc.Format("report", new LocArg("players", 3), new LocArg("rooms", 1));
// 3 players in 1 room
# stands for the number of the branch it sits in. Each argument names its own rule, so
the Polish translation of the same key can use four forms for one number and two for the
other without your C# changing.
Exact matches say what a category cannot:
{ "inbox": "{n, plural, =0 {no messages} one {# message} other {# messages}}" }
offset shifts the number the sentence reasons about — useful when part of the count
is already named:
{ "likes": "{n, plural, offset:1 =0 {nobody} =1 {{name}} other {{name} and # others}}" }
select branches on a value rather than a count, which is the general form of gender:
{ "won": "{gender, select, female {Sie hat} male {Er hat} other {Es hat}} gewonnen" }
selectordinal uses the ranking rules:
{ "place": "{n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} place" }
These nest freely, and plain {name} and {name:N0} still work inside them. A pattern
without a comma never touches the ICU parser at all, so simple strings stay simple.
Two rules worth knowing:
- Every plural, selectordinal and select argument must have an
otherbranch. The parser rejects patterns without one, because the categories your language uses are not the categories the translation's language uses. - Literal braces are
{{and}}, not ICU's apostrophe quoting — apostrophe quoting silently changes the meaning of ordinary apostrophes in French and Italian. Inside a branch a}always closes it, so a literal closing brace belongs in the surrounding text.
Ranking, not counting: 1st, 2nd, 3rd
"You finished 3rd" is a different rule set from "3 files", and a language can need a different number of forms for each. English has two counting categories but four ordinal ones — and 11th, 12th, 13th break the pattern that 1st, 2nd, 3rd set up. German has exactly one: a full stop after the digit.
{
"rank": { "$plural": {
"one": "{n}st place",
"two": "{n}nd place",
"few": "{n}rd place",
"other": "{n}th place"
} }
}
loc.Ordinal("rank", 1); // 1st place
loc.Ordinal("rank", 2); // 2nd place
loc.Ordinal("rank", 3); // 3rd place
loc.Ordinal("rank", 11); // 11th place ← not 11st
loc.Ordinal("rank", 22); // 22nd place
The rank is supplied as {n}, the way Plural supplies {count}. German needs only a
plain string:
{ "rank": "{n}. Platz" }
Most languages use a single ordinal form, so the rule table is short by nature. English, Swedish and Italian are the ones that differ; anything else gets one form, which is right far more often for ranking than it is for counting.
Which languages are supported
| Rule | Languages |
|---|---|
| One form only | ja zh ko th vi id ms my km lo yue |
one / other, on whole numbers |
en de nl sv fi et it ca sw |
one / other, on exact 1 |
es tr el hu bg af gl az ka kk ky uz nb nn no |
one covers 0 and 1 |
fr pt |
one covers 0 and exactly 1 |
hi bn fa gu am |
| Four forms | ru uk be · pl · cs (cz) sk |
| Six forms | ar |
Region codes work too — pt_br, pt-BR and PT_br all use the Portuguese rule.
A language not on this list gets one form only. That is right for Japanese and Chinese but wrong for, say, Romanian. If you ship a language that is not listed, its plural rule needs adding to the library first.
5. Splitting your text into banks
You may not need this. One file per language works, and a project with a few hundred strings can stay that way forever. Read on when one of these becomes true:
- Your text no longer fits comfortably in one file
- A chapter, level or DLC has text you would rather not keep in memory the whole time
- You want a pack that replaces some of the base text without editing it
- Different parts of your text are written by different people
A bank is just a name you choose. It is the filename, without .json. Nothing more —
"ui" in these examples is an example, not a reserved word.
Localization/
├── de/
│ ├── ui.json ← bank "ui"
│ ├── items.json ← bank "items"
│ └── chapter2.json ← bank "chapter2"
└── en/
├── ui.json
├── items.json
└── chapter2.json
The name "ui" is not special — it was only an example. One bank for everything is
perfectly fine:
Localization/
├── de/strings.json
└── en/strings.json
Why bother splitting
So you can load and unload parts of your text as needed — one bank per screen, per chapter, per DLC. A big game does not need chapter 9's dialogue in memory during chapter 1.
loc.Load("ui"); // load one
loc.Load("ui", "items", "quests"); // load several, in one swap
loc.LoadAll(); // load everything, explicitly
await loc.LoadAsync("chapter2"); // load without freezing your UI
loc.Unload("chapter2"); // free it again
var loaded = loc.Loaded; // what is in memory right now
The first explicit Load turns the automatic one off. Until you call one, a lookup
loads everything; once you have said what you want, nothing loads behind your back. That
is the whole rule — there is no flag for it.
Loading a group of banks by tag
Listing banks at every call site means revisiting them all when one bank gets split in two. Tag them in the schema instead:
Localization/
├── _schema/
│ ├── ui.json
│ ├── hud.json
│ └── chapter1.json
├── de/
│ ├── ui.json
│ ├── hud.json
│ └── chapter1.json
└── en/
└── …
// Localization/_schema/ui.json
{ "bank": { "tags": ["always", "interface"] } }
loc.Load(loc.Schema.BanksWithTag("always")); // ui and hud
loc.Schema.BanksWithTag("story"); // ["chapter1"]
loc.Schema.TagsFor("ui"); // ["always", "interface"]
There is no LoadByTag. A tag is a list of bank names, and Load already takes those —
which is also why the asynchronous version needs nothing extra:
await loc.LoadAsync(loc.Schema.BanksWithTag("always"));
Tags are optional, like the rest of the schema. Without them every bank simply has none.
One word you will meet in the API: catalogue
- A bank is a name.
"ui". - A catalogue is the loaded contents of one bank in one language.
Localization/
├── de/ui.json ← catalogue 1 ┐
├── en/ui.json ← catalogue 2 ├─ both are bank "ui"
└── _schema/ui.json ┘ not a catalogue: it declares, it does not translate
Loading bank "ui" here loads two catalogues and stacks them in fallback order. You
rarely deal with catalogues directly — it only comes up if you write your own storage
(section 11).
Banks can override each other
A bank loaded later wins. This is how DLC and mods patch existing text:
Localization/
└── de/
├── base.json { "title": "Basis", "subtitle": "Nur in base" }
└── dlc.json { "title": "Erweiterung" }
loc.Load("base");
loc.Get("title"); // Basis
loc.Get("subtitle"); // Nur in base
loc.Load("dlc"); // loaded later, so it wins
loc.Get("title"); // Erweiterung ← replaced
loc.Get("subtitle"); // Nur in base ← dlc.json has no "subtitle", so base still answers
loc.Unload("dlc");
loc.Get("title"); // Basis ← back to normal
The overriding bank only lists the keys it changes. Everything else falls through, key by key. It is not an all-or-nothing swap.
What happens when two banks use the same key
Later-loaded wins, and unloading restores what was underneath. That is the mechanism, and it is the same whether you meant it or not — which is the problem.
loc.Load("ui", "items"); // if both define "title", items wins
Nothing warns you. Two people adding a title key to different banks get a screen that
shows the wrong one, and which one depends on load order.
The schema is what tells the two apart. A key belongs to exactly one bank, and two banks declaring the same key is an error the build refuses:
Key 'title' is declared by both 'ui' and 'items'. A key belongs to one bank,
or a later bank silently overrides an earlier one.
A deliberate override says so, and is then allowed to replace keys it does not own:
// Localization/_schema/dlc.json
{ "bank": { "overrides": true } }
An override bank need not carry every key — only the ones it replaces — but it still cannot invent keys nobody declared.
| Without a schema | With a schema | |
|---|---|---|
| Two banks, same key, by accident | silently overrides | build error |
| An override bank replacing text | works | works, once declared |
| An override bank inventing a key | works | build error |
So: the override behaviour is deliberate and useful, and the schema is what stops it from also being a way to shadow a key by accident. Without a schema you get the behaviour and none of the protection.
How a lookup actually searches
With German active, English as fallback, and banks base then dlc loaded,
loc.Get("title") walks this list and stops at the first hit:
1. de/dlc.json ← active language, last-loaded bank
2. de/base.json ← active language, earlier bank
3. en/dlc.json ← first fallback language
4. en/base.json
5. the key itself ← nothing had it
Later banks beat earlier banks. Earlier languages beat later languages. The two never interfere with each other.
6. When a translation is missing
Nothing checks that your language files match. Each file lists whatever it happens to
contain. If de/ui.json has menu.play and en/ui.json does not, you get no error —
the files simply differ.
That is different from a translation editor, where keys are defined once and then filled in per language. On disk there is no such list unless you make one.
Three things help, in order of usefulness.
The best one: run hxloc check before you ship
This catches a forgotten key before anyone sees it. See section 8.
Declare what exists, in _schema
The _schema folder from the top of this document is
what makes that check possible: it turns "the files disagree" from something you find on a
screen into something a build refuses. One file per bank, listing the keys that are
supposed to exist:
Localization/
├── _schema/
│ └── ui.json ← what exists, and what each key means
├── en/
│ └── ui.json ← your source language
└── de/
└── ui.json ← only what has been translated
// Localization/_schema/ui.json
{
"bank": { "tags": ["always", "interface"] },
"keys": {
"menu.play": { "context": "Main menu button", "maxLength": 12 },
"welcome": { "variables": { "name": "the player's display name" } },
"files": { "forms": "plural" }
}
}
The schema is the authority. hxloc check compares every language against it —
including your source language, which without a schema is the one thing never checked,
because it defines the answer. A key the schema does not declare is an error wherever it
appears, since the compiler drops it and it would then look missing at runtime.
| Field | |
|---|---|
context |
What the string is for, written for whoever translates it. |
maxLength |
Longest a translation may be, in characters. Reported as a warning. |
variables |
Placeholder names the text may use. Anything else is an error. |
forms |
single, plural, gender or case — the shape the value must take. |
bank.tags |
Groups. Schema.BanksWithTag("hud") turns one into bank names. |
Everything is optional, including the schema itself: without one, keys are simply whatever the catalogues contain.
It never ships. The schema describes sources; a compiled catalogue is the result of
applying it. hxloc compile does not copy it, and if you ship JSON directly, exclude it:
<None Include="Localization\**\*.json"
Exclude="Localization\_schema\**"
CopyToOutputDirectory="PreserveNewest" />
An untranslated key falls back — make that visible while you work
A key with no translation in the active language is a content problem: someone has to write the text. It never crashes, and in a shipped build the fallback answers quietly. But during development that is exactly what hides the gap — English on a German screen reads as finished work.
using var loc = new Localizer("de")
{
Fallback = ["en"],
ShowFallback = FallbackDisplay.Brackets, // development only
};
| Mode | Output | For |
|---|---|---|
None |
Play |
Shipped builds. The default. |
Brackets |
⟦Play⟧ |
Readable, unmistakable. |
Accented |
⟦Pḷäÿ⟧ |
Impossible to overlook. |
Elongated |
⟦Play···⟧ |
Layout overflow before the translation exists. |
Rtl |
reversed | Testing a mirrored layout. |
Elongated earns its keep: German runs about 30% longer than English, so it shows the
button that will overflow before anyone has written the text that overflows it.
A regional code and its base count as the same language, so de_AT falling through to
de is not marked — that is the layout working, not a gap.
A log of what actually went missing at runtime
using HxLocal.Diagnostics;
var missing = new MissingKeyLog();
using var loc = new Localizer("de") { MissingKeys = missing };
// … run your app …
foreach (MissingKey m in missing.Drain())
Console.WriteLine($"{m.Language}: {m.Key}");
This only sees keys something actually asked for. A key on a screen nobody opened stays
silent — which is why hxloc check matters more.
To make missing keys fail your tests:
new Localizer("de") { ThrowOnMissing = true }
The Try methods are unaffected — they never throw and never report, because writing
Try says you are handling the miss yourself.
Leave that off in a shipped build. A missing string is a cosmetic problem; crashing on one turns it into a broken app.
Telling a real hit from a miss
Get returns the key when it finds nothing, which looks the same as a translation that
happens to equal its key. When that matters:
if (loc.TryGet("greeting", out string text))
Console.WriteLine(text); // definitely found
else
Console.WriteLine("(untranslated)");
loc.Has("greeting"); // just checking
Every method that resolves text has one, and they all read the same way:
TryGet(key, out text) |
|
TryFormat(key, out text, …args) |
|
TryPlural(key, count, out text, …args) |
|
TryOrdinal(key, rank, out text, …args) |
|
TryVariant(key, tag, out text, …args) |
On a miss they hand back the key — the same thing the plain call would have rendered — so
ignoring the bool still leaves you with something to display.
Going further
7. Der, die, das — gender and case
Counting is not the only thing that changes a word. Two others come up constantly once you leave English:
Gender. "The merchant" is one word in English. In German it is der Händler or die Händlerin depending on who you mean, and the article changes with it. A game that lets a player pick a character has this problem on every screen that mentions them.
Grammatical case. In Polish the city is Kraków, but "from Kraków" is z Krakowa and "in Kraków" is w Krakowie. The word itself changes with its role in the sentence. You cannot store one form and glue prepositions in front of it.
Both work like plurals — several forms under one key — except that you invent the
labels, because there is no universal list. Gender might be m/f/n or
masculine/feminine; case runs from two forms in some languages to fifteen in others.
{
"merchant": { "$gender": { "m": "der Händler", "f": "die Händlerin" } },
"city": { "$case": { "nominative": "Kraków", "genitive": "Krakowa" } }
}
loc.Variant("merchant", "f"); // die Händlerin
loc.Variant("city", "genitive"); // Krakowa
loc.Variant("merchant", "x"); // unknown label → falls back to the first/other form
There is no fixed list of labels because grammatical case ranges from two forms in some languages to fifteen in others. Use whatever names make sense to you and your translators.
Asking for such a key with plain Get returns its fallback form:
loc.Get("merchant"); // der Händler
8. Checking your translations with hxloc
Here is the failure this prevents. You add a key, translate it in English, and forget German. Nothing complains: the German file simply does not have it, the fallback quietly serves English, and the missing-key log only ever sees keys that something asked for — so a screen nobody opened during testing stays silent until a player finds it.
Nothing inside your program can catch that, because from the inside a missing key looks exactly like a key that was never used. It has to be caught by something that reads all the files at once, before you ship.
dotnet tool install -g H073.Local.Tool
hxloc check ./Localization
Schema: 412 keys in 3 bank(s)
de/ui
error 'welcome' drops placeholder(s) {name} — they render as nothing
error 'welcome' uses unknown placeholder(s) {player} — they render literally
error missing key 'menu.settings'
pl/ui
warn 'files' has no "few", "many" form(s); pl needs "one", "few", "many", "other"
warn key 'extra' is not in the reference
3 error(s), 2 warning(s).
It compares every language against the schema, or — if you have no schema — against the language with the most keys, and reports:
- keys present in the reference but missing from a language
- keys in a language that the reference does not have
- placeholders a translation dropped or invented
- plural categories the language needs but the file does not have
- malformed JSON, and two keys that collide internally
It exits with a non-zero code when it finds errors, so it fails a CI build:
- run: dotnet tool install -g H073.Local.Tool
- run: hxloc check ./Localization
| Option | |
|---|---|
--reference <code> |
Compare against this language. Only used when there is no schema. |
--strict |
Fail on warnings too, not just errors. |
--quiet |
Print only the summary line. |
9. Hot reload while you work
Writing text is not like writing code: you rewrite the same line ten times to get the rhythm right. Restarting the app for each attempt makes that unbearable, and it is the main reason to point a development build at JSON rather than at compiled files.
Edit a JSON file, alt-tab, see the change — no restart:
loc.WatchForChanges();
loc.Reloaded += (_, _) => RedrawEverything();
Or refresh manually:
loc.Reload();
Reload() also rescans for new language folders.
10. Faster startup with compiled files
You can skip this entire section. JSON supports everything in this document, your app works, and nothing here changes a single line of your code. It is a deployment choice, not a feature.
Come back when one of these is true:
- Startup time matters — loading is 126× faster
- Memory matters — a loaded catalogue holds 2.5× less, and there is a setting that takes it to 60× less
- You would rather not ship your text as files anyone can read and edit
The idea: turn your JSON into a compact binary format called LOC3 as part of your build, and ship only that.
MyGame/
├── Localization/ ← you edit this; stays in source control
│ ├── _schema/ui.json
│ ├── de/ui.json
│ └── en/ui.json
│
└── Localization.compiled/ ← the build makes this; only this ships
├── de/ui.loc3
└── en/ui.loc3 ← no _schema: it describes sources
Step 1 — compile during the build
hxloc compile ./Localization ./Localization.compiled
de/ui 412 keys 18,204 → 14,880 bytes
en/ui 412 keys 17,993 → 14,612 bytes
2 catalogue(s) → ./Localization.compiled 29,492 bytes, 82% of the JSON
Wire it into your .csproj so it happens automatically:
<Target Name="CompileLocalization" BeforeTargets="Build"
Condition="'$(Configuration)' == 'Release'">
<Exec Command="hxloc compile ./Localization ./Localization.compiled" />
</Target>
Step 2 — point at the compiled folder in release builds
string folder =
#if DEBUG
"Localization"; // editable, hot-reloadable
#else
"Localization.compiled"; // fast, opaque
#endif
using var loc = new Localizer("de") { Path = folder, Fallback = ["en"] };
A folder name, not a different type. Every method works the same either way.
Do not compile at runtime. If your app compiles JSON at startup, the JSON is still in your build and you pay to convert it every launch — worse than just reading the JSON. Compiling belongs in the build.
What you gain and give up
Measured on a 5,000-key catalogue — see Benchmarks for the method:
| JSON | Compiled | |
|---|---|---|
| Load a bank | 3,021 µs | 24 µs |
| Allocated while loading | 2.93 MB | 73 KB |
| Memory retained after loading | 4.19 MB | 1.68 MB, or 69 KB tuned |
| One lookup | 47 ns | 42 ns |
| One lookup, precomputed key | 6.6 ns | 7.8 ns |
| Hot reload | yes | no point |
| Readable in the shipped build | yes | no |
| Key names available for reports | yes | only with --keys |
Compiled catalogues load 126× faster, allocate 40× less, and hold 2.5× less memory.
Lookups are a wash — within about 15% either way, depending on whether you hand it a
string or a precomputed LocKey.
So the choice is about startup and footprint, not lookup speed. If your app loads once and runs for hours with a few thousand keys, JSON costs three milliseconds you will never notice. If you load and unload banks per scene, start cold often, or care about memory, the compiled format wins by two orders of magnitude on the part that matters.
Small files come out larger than their JSON — a 64-byte header, 20 bytes of index per key and a bucket table that is at least 32 bytes. The size win only appears once key names stop being a significant fraction of the file.
| Option | |
|---|---|
--keys |
Keep key names in the output. Bigger files, better error reports. |
--language <code> |
Compile only this language. Repeatable. |
--clean |
Delete the output folder first. |
--quiet |
Print only errors. |
11. Advanced
Nothing here is needed to use the library. Each part answers a specific question that only comes up at some scale or in some setting:
| Hot paths | Text redrawn every frame |
| Threading | Loading on a background thread while the UI reads |
| Custom storage | Text that does not live in files |
| Cultures | Overriding how numbers and dates format |
| Compiling from code | Building .loc3 without the CLI |
| Downloaded catalogues | Files that arrive over a network |
| Memory | Large catalogues, or many at once |
Speeding up text you fetch every frame
Looking up a key means encoding it to UTF-8 and hashing it — 41 ns for a 30-character key, which is most of what a lookup costs. For text drawn every frame, do it once:
static readonly LocKey PlayLabel = new("menu.play");
loc.Get(PlayLabel); // skips the hashing
Measured: 7× faster on JSON (47 ns → 6.6 ns) and 5× on compiled catalogues (42 ns → 7.8 ns).
Lookups allocate nothing, in either format, hit or miss. Neither does a template with
no placeholders — Format on such a key returns the stored string itself.
Formatting is where the cost is: one placeholder is 157 ns and 232 bytes, three with a number format 319 ns and 376 bytes. If that matters in your loop, cache the formatted result rather than the key.
Benchmarks
dotnet run --project benchmarks/HxLocal.Benchmarks -c Release -- --filter "*"
BenchmarkDotNet, .NET 10, 5,000 keys in two languages, keys of realistic length and dotted shape, values long enough that UTF-8 decoding is not free.
Loading one 5,000-key bank
| Time | Allocated | |
|---|---|---|
| JSON: parse | 3,021 µs | 2.93 MB |
| LOC3: validate index + buckets | 24 µs | 73 KB |
| LOC3: validate + verify checksum | 487 µs | 73 KB |
| LOC3: validate + decode every value | 431 µs | 1.08 MB |
| JSON: read file + parse | 4,073 µs | 5.47 MB |
| LOC3: read file + validate | 155 µs | 670 KB |
| Compile JSON → LOC3 | 5,638 µs | 5.12 MB |
Lazy decoding holds up even in the worst case: touching every value immediately after loading is still 5.4× faster than parsing the JSON.
One lookup
| Time | Allocated | |
|---|---|---|
| JSON, key as string | 47 ns | none |
JSON, precomputed LocKey |
6.6 ns | none |
| LOC3, key as string | 42 ns | none |
LOC3, precomputed LocKey |
7.8 ns | none |
| LOC3, miss through every tier | 45 ns | none |
LocHash on a 30-character key |
41 ns | none |
Formatting
| Time | Allocated | |
|---|---|---|
| No placeholders | 51 ns | none |
| One placeholder | 130 ns | 232 B |
Three, one with :N0 |
245 ns | 376 B |
| Plural: pick the form and substitute | 272 ns | 296 B |
PluralRules.Select alone |
16 ns | none |
Memory retained after loading
Allocation during a load is not what an application lives with afterwards.
| Retained | Times its file size | |
|---|---|---|
| JSON catalogue | 4.19 MB | 6.6× |
| LOC3, nothing read yet | 637 KB | 1.1× |
| LOC3, every value decoded | 1.68 MB | 2.8× |
| LOC3 memory-mapped, every value decoded | 1.08 MB | 1.8× |
| LOC3 memory-mapped, cache capped at 256 | 69 KB | 0.1× |
JSON materialises every string and a dictionary entry at load. The compiled reader keeps the raw bytes and decodes only what is asked for.
Two options cut it further, and both live on FileSource
(see below). Mapping moves the file out of the managed
heap; capping the cache stops it retaining every string it has ever decoded. Together they
take a 5,000-key bank from 1.68 MB to 69 KB — sixty times less than the same catalogue
as JSON.
Why lookups are slower on the compiled format
Per lookup, as the catalogue grows:
| Keys | JSON | LOC3 |
|---|---|---|
| 100 | 3.9 ns | 4.9 ns |
| 1,000 | 4.1 ns | 5.2 ns |
| 10,000 | 4.7 ns | 6.0 ns |
| 50,000 | 5.1 ns | 5.4 ns |
Both are flat. LOC3 stays within 10% of the dictionary across a 500× range, and closes to 3% at 50,000 keys.
Format version 2 binary-searched a sorted index instead, and climbed with log n — 9.8 ns
at 100 keys, 25.1 ns at 50,000, four to five times the dictionary. Version 3 added the
hash bucket table that GNU gettext's .mo files use, which is what flattened the curve.
The remaining gap is that a bucket lookup touches three memory regions — bucket, index
entry, decode cache — where a dictionary touches two.
Concurrent lookups
Each thread performs 20,000 lookups, so total work grows with the thread count.
| Threads | Time | Per lookup | Throughput |
|---|---|---|---|
| 1 | 264 µs | 13.2 ns | 76 M/s |
| 2 | 275 µs | 6.9 ns | 146 M/s |
| 4 | 334 µs | 4.2 ns | 239 M/s |
| 8 | 426 µs | 2.7 ns | 376 M/s |
Eight times the threads gives about five times the throughput. A lock anywhere on the
lookup path would flatten that curve near 1×, so this is the claim of lock-free reads
measured rather than argued. The shortfall from 8× is core count and Parallel.For
overhead; the few kilobytes allocated are its bookkeeping, not the lookups.
params versus ReadOnlySpan. Both allocate exactly 376 bytes for a three-argument
format on .NET 10 — the params array does not escape the call, so the JIT stack-allocates
it — and the span version measures slightly slower. There is no allocation reason to
prefer one. This was previously reported as unmeasured because the benchmark handed both
overloads the same pre-built array; it now builds the arguments inline for the params
case. Not measured on .NET 8, where the array is more likely to be real.
Threading
Safe. Lookups take no lock and never see a half-finished load, so you can stream banks in on a background thread while your UI reads. Loading operations serialise against each other automatically.
Storing text somewhere other than files
Implement ILocaleSource — embedded resources, a download cache, a database:
public interface ILocaleSource
{
string Description { get; }
bool ProvidesKeyNames { get; }
bool TryLoad(string language, string bank, out ICatalog catalog);
IReadOnlyList<string> DiscoverLanguages();
IReadOnlyList<string> DiscoverBanks(string language);
string? ResolvePath(string language, string bank); // null if not file-backed
ValueTask<ICatalog?> TryLoadAsync(string language, string bank, CancellationToken ct);
LocaleSchema DiscoverSchema(); // LocaleSchema.Empty if none
}
The last two have defaults, so an existing source keeps compiling. TryLoadAsync falls
back to the thread pool; DiscoverSchema returns nothing, which is right for a source
that has no schema — and for compiled catalogues, which are the result of applying one.
If your layout is {root}/{language}/{bank}{extension}, derive from
DirectoryLocaleSource and write two members instead:
sealed class YamlSource(string root) : DirectoryLocaleSource(root)
{
protected override IReadOnlyList<string> Extensions => [".yaml"];
public override bool ProvidesKeyNames => true;
protected override ICatalog LoadFile(string path, string language, string bank)
=> /* parse and return an ICatalog */;
}
Either way, it goes in the same place:
using var loc = new Localizer("de") { Source = new YamlSource("Localization") };
Source overrides Path, and nothing else in this document changes.
Custom number and date formatting per language
new Localizer("de")
{
CultureFor = code => CultureInfo.GetCultureInfo(code.Replace('_', '-')),
}
By default, language codes are read as standard culture names (underscores allowed), and an unrecognised code falls back to invariant formatting rather than throwing.
Compiling from code instead of the CLI
byte[] binary = JsonCatalog.Load("Localization/de/ui.json", "de", "ui").Compile();
File.WriteAllBytes("out/de/ui.loc3", binary);
Trading memory for nothing much
Two options on FileSource cut what a loaded catalogue holds. Both are off by default.
using var loc = new Localizer("de")
{
Source = new FileSource("Localization.compiled")
{
MemoryMap = true, // keep the file out of the managed heap
CacheLimit = 512, // retain at most this many decoded strings
},
};
Measured on a 5,000-key bank, both together take it from 1.68 MB to 69 KB.
MemoryMap maps the file instead of reading it into a byte[]. The bytes move
into the operating system's page cache, so the garbage collector stops walking them, two
processes reading the same file share one copy, and the kernel may drop clean pages under
memory pressure and fault them back on demand. Lookups are unchanged — reading a mapped
page is the same instruction as reading an array.
Turn it on for large banks, or when many banks stay loaded. Leave it off for small ones: each mapping costs a file handle and a view, which is not worth it for a few kilobytes.
CacheLimit caps how many decoded strings a catalogue retains. Values are decoded
from UTF-8 on first use and kept, so a bank whose every key gets touched ends up holding
its entire text as managed strings — the single largest part of a loaded catalogue.
Turn it on when a catalogue holds far more text than is ever on screen at once — a game with 50,000 lines of dialogue showing twelve. Leave it off when the catalogue is small enough that keeping everything is fine, which is most of the time.
The cost is real but small: an evicted value is decoded again on its next use, around 200 ns. Set the limit comfortably above your working set. Note that the cache is emptied wholesale rather than entry by entry — that is what keeps lookups lock-free.
Both are ignored for JSON, and neither has a JSON equivalent to be ignored in favour of. Mapping works because the compiled format reads values straight out of file bytes; parsing JSON materialises managed objects by definition, so there is nothing for a mapping to avoid. A capped cache assumes there is something left to decode later — in a parsed catalogue the strings are the storage.
So setting them on a folder of JSON is not an error, it simply has no effect. That matters
because the same FileSource reads both: a project that compiles for release and reads
JSON while developing configures the source once, and the two options switch themselves on
along with the format they belong to.
Verifying downloaded catalogues
Source = new FileSource("downloaded") { VerifyHash = true },
Checks each file's stored checksum. Off by default because it reads the whole file — worth turning on when catalogues arrive over a network rather than in your build.
Reference
Every file format rule
The same format shown near the top, written out exhaustively. Nothing here is new; this is where to look when you need the exact rule rather than an example.
One JSON object per file. Nesting builds dotted keys.
{
"menu": {
"play": "Spielen", // → key "menu.play"
"settings": {
"audio": "Ton" // → key "menu.settings.audio"
}
},
"stones": { "$plural": { "one": "Stein", "other": "Steine" } },
"merchant": { "$gender": { "m": "Händler", "f": "Händlerin" } },
"city": { "$case": { "nominative": "Kraków", "genitive": "Krakowa" } },
"untranslated": null
}
| What you write | What it means |
|---|---|
"key": "text" |
A plain translation. |
"key": { … } |
A group; keys are joined with .. |
"key": { "$plural": {…} } |
Plural forms, labelled with CLDR category names. |
"key": { "$gender": {…} } |
Gender forms, labelled however you like. |
"key": { "$case": {…} } |
Case forms, labelled however you like. |
"key": null |
Not translated yet — falls through to the next language. |
"key": 42 or true |
Accepted, read as text. |
"key": ["a", "b"] |
Rejected with an error. |
Comments and trailing commas are allowed, since people hand-edit these files.
Why null and not "". An empty string is a translation that happens to be blank,
and it hides the fallback. null means "nobody has translated this", so the next language
answers.
Why arrays are rejected. A list cannot say which form is which. ["a","b"] means
one, other in English but one, few in Polish — no code can index that correctly for
more than one language. Forms always carry their label.
Placeholder syntax
{name} |
Insert the value called name. |
{name:N0} |
Insert it using a .NET format string. |
{{ }} |
A literal { or }. |
API reference
Every public member, grouped. The library ships XML documentation, so IntelliSense explains each of these as you type — including the reasoning, which is often the part that matters.
Localizer
Constructor
| Member | |
|---|---|
Localizer(language = "en") |
Everything else is a property. |
Properties
| Member | Default | |
|---|---|---|
Path |
"Localization" |
The folder holding your language folders. |
Source |
null |
An ILocaleSource for anything that is not a folder. Overrides Path. |
Fallback |
[] |
Tried in the order given. |
RegionalFallback |
true |
de_AT also consults de. |
ShowFallback |
None |
Mark text that came from a fallback. |
MissingKeys |
null |
An IMissingKeyReporter. |
ThrowOnMissing |
false |
For tests and CI. Never affects the Try methods. |
CultureFor |
null |
Custom language → culture mapping. |
Setting any of them re-reads what is currently loaded.
Language
| Member | |
|---|---|
Language · Culture · IsRightToLeft |
Current code, its formatting culture, and whether it mirrors. |
Languages |
Every language the source can serve. |
SetLanguage(lang) · SetLanguageAsync(lang, ct) |
Switch. Raises LanguageChanged. |
Loading — optional; the first lookup loads everything if nothing else has.
| Member | |
|---|---|
Load(params banks) · Load(names) |
These banks. Later-loaded wins. |
LoadAll() |
Every bank any tier offers. |
LoadAsync(params banks) · LoadAsync(names, ct) |
Genuinely asynchronous reads, then one snapshot swap. |
LoadAllAsync(ct) |
|
Unload(params banks) · Unload(names) · UnloadAll() |
|
Loaded |
Bank names something actually serves, in load order. |
Reload() |
Re-read everything from disk. |
Schema |
What the _schema folder declares. BanksWithTag · TagsFor · Declares · KeysOf. |
WatchForChanges(debounce?) · Dispose() |
|
LanguageChanged · Reloaded |
Events. |
Reading — every one of these has a Try twin that reports the miss instead of
rendering the key, and never throws or reports.
| Member | |
|---|---|
Get(key) · Get(in LocKey) |
Returns the key itself if not found. |
Format(key, …args) |
Fills in placeholders. |
Plural(key, count, …args) |
Picks the right form; supplies {count}. |
Ordinal(key, rank, …args) |
Ranking form — 1st, 2nd, 3rd; supplies {n}. |
Variant(key, label, …args) |
Gender, case, or a named plural form. |
Has(key) |
Whether any tier can serve it, without materialising the text. |
Keys() |
Every key the loaded catalogues can serve, or null if none kept names. |
Which plural category a bare number falls into is PluralRules.Select(lang, n) and
PluralRules.SelectOrdinal(lang, n); a language's name for a picker is
LanguageNames.For(code).
Format, Plural, Ordinal and Variant each have two forms: params LocArg[] and
ReadOnlySpan<LocArg>. Use whichever reads better. On .NET 10 the params array does
not escape the call, so the JIT stack-allocates it — both overloads allocate the same
376 bytes for a three-argument format, and the span version measures slightly slower.
On .NET 8 the array is more likely to be real, so the span overload may still pay there;
that is not measured.
FileSource
The folder source. You only name it to change one of these; otherwise Path builds it.
| Property | Default | |
|---|---|---|
Accepts |
Any |
Json or Compiled to insist on one storage format. |
MemoryMap |
false |
Map compiled files instead of reading them onto the heap. |
CacheLimit |
0 |
Decoded strings a compiled catalogue may retain. 0 is no limit. |
VerifyHash |
false |
Check each compiled file's checksum on load. |
The last three are ignored for JSON, which has already materialised every string.
Other types
| Namespace | Types |
|---|---|
HxLocal |
Localizer · LocKey · LanguageNames · LanguageChangedEventArgs |
HxLocal.Format |
LocArg · MessageFormatter · IcuMessage · VariantSet · FallbackDisplay · BidiIsolate · Loc3Reader · Loc3Writer · Loc3Format |
HxLocal.Plural |
PluralCategory · PluralRules · PluralOperands · PluralCategoryTags |
HxLocal.Sources |
ILocaleSource · DirectoryLocaleSource · FileSource · CatalogueFormat · ICatalog · JsonCatalog · LocaleSchema · KeyDefinition |
HxLocal.Diagnostics |
MissingKeyLog · IMissingKeyReporter · MissingKey |
HxLocal.Hashing |
LocHash |
Everything carries XML documentation, so IntelliSense explains each member as you type.
Coming from 1.x
Your JSON files do not change. Version 1.x was JSON-only and used the same nested, dotted layout. What changes is the C# that calls the library.
| 1.x | 2.0 |
|---|---|
new Localizer { FilePath = dir } |
new Localizer("de") { Path = dir } |
LoadLanguage("de") |
SetLanguage("de") |
GetString(key) |
Get(key) |
GetString(key, a, b) |
Format(key, new LocArg("name", a)) |
ListAvailableLanguages() |
Languages |
Reload() |
Reload() |
OnLanguageChange |
LanguageChanged |
LanguageNotFoundException |
Gone — a language with no files simply falls back. |
JsonParseException |
InvalidDataException |
IOOperationException |
IOException |
| Needs Newtonsoft.Json | No dependencies. |
Three things you actually have to do
1. Rename your placeholders. 1.x used numbered ones:
- "welcome": "Welcome, {0}!"
+ "welcome": "Welcome, {name}!"
- loc.GetString("welcome", playerName);
+ loc.Format("welcome", new LocArg("name", playerName));
Named placeholders survive translation. A translator reordering a sentence cannot break
{name}, but silently breaks {0} and {1}.
2. Give your file a bank name. 1.x loaded one file per language. Move it into a language folder and load it by name:
before after
────── ─────
Localization/ Localization/
├── en.json ├── en/
└── de.json │ └── strings.json
└── de/
└── strings.json
You do not have to call anything to load it — the first lookup does. Load("strings") is
there for when you want to say when.
3. Set a fallback language. 1.x had none. With one, untranslated keys show real text instead of raw keys:
new Localizer("de") { Fallback = ["en"] };
Everything else — plurals, gender, hot reload, missing-key reports, compiled files — is optional and additive. Migrate the calls first; adopt the rest whenever you like.
Nothing to convert on the binary side: 1.x never had a compiled format.
The LOC3 binary format
Version 3. All integers little-endian. Loc3Format holds every constant. This section is
for anyone writing a compiler in another language; you do not need it to use the library.
Header — 64 bytes
| Offset | Field |
|---|---|
| 0 | Magic "LOC3" |
| 4 | uint16 format version (3) |
| 6 | uint16 header flags — bit 0 = key name table present |
| 8 | Language code, 8 bytes ASCII, NUL-padded |
| 16 | uint32 key count |
| 20 | uint32 index offset |
| 24 | uint32 value data offset |
| 28 | uint32 value data length |
| 32 | uint32 key name table offset (0 = absent) |
| 36 | uint32 key name table length |
| 40 | uint32 bucket table offset |
| 44 | uint32 bucket count — a power of two |
| 48 | uint64 FNV-1a 64 of the value region |
| 56 | 8 bytes reserved, must be zero |
Bucket table — bucketCount entries of uint32
Each entry is an index into the key index, or 0xFFFFFFFF for an empty slot. Open
addressing with linear probing: start at hash & (bucketCount - 1) and step forward,
wrapping, until the indexed entry's hash matches or a slot is empty.
The table is sized to the next power of two at or above keyCount × 4/3, so it never
exceeds three-quarters full. A compiler must probe in exactly this order or some keys
become unreachable — with no error and no crash, just a translation that never appears.
Version 2 had no bucket table and binary-searched the index instead, which cost log n
probes. GNU gettext's .mo files use the same bucket arrangement.
Index — one 20-byte entry per key, sorted ascending by hash
The index stays sorted even though lookups go through the bucket table. Sorting makes a compiler's output deterministic and lets a reader prove, in one linear pass, that no two keys collided.
| Offset | Field |
|---|---|
| 0 | uint64 key hash — FNV-1a 64 of the UTF-8 key |
| 8 | uint32 value offset, relative to the value region |
| 12 | uint32 value length |
| 16 | uint16 entry flags |
| 18 | uint8 variant count — 0 for a plain string |
| 19 | uint8 reserved, must be 0 |
Values. A plain entry is exactly valueLength bytes of UTF-8, with no terminator. A
labelled entry is variantCount repetitions of: uint8 label length, label bytes (ASCII),
uint16 text length, text bytes (UTF-8).
Key name table, when present: keyCount repetitions of uint16 length plus that many
UTF-8 bytes, in index order.
The hash is FNV-1a 64 — offset basis 0xcbf29ce484222325, prime 0x100000001b3.
Test vectors: "" → 0xcbf29ce484222325, "a" → 0xaf63dc4c8601ec8c, "foobar" →
0x85944171f73967e8.
Limits and known gaps
Format ceilings
| Labelled forms per key | 255 |
| Label length | 255 bytes |
| One labelled form's text | 65 535 bytes (plain strings are unlimited) |
| Text per bank | 4 GiB |
| Language code | 8 bytes ASCII |
Not implemented
| Gap | What to do instead |
|---|---|
Per-entry cache eviction — the decode cache is emptied wholesale when CacheLimit is passed, not evicted least-recently-used. Keeping lookups lock-free was worth more than precision. |
Set the limit above your working set. |
| Relative dates ("3 days ago") | Its own CLDR dataset; a separate library's job. |
| Currency and date helpers | value.ToString("C", loc.Culture) — .NET already does this. |
Everything above is a scope decision, not an oversight. What is measured but imperfect — the wholesale cache clear — is documented where the option lives.
The binary format is not frozen yet. This package is prerelease precisely so it can
still change; treat 2.0.0-alpha .loc3 files as disposable and recompilable.
Licence
Apache-2.0
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
-
net8.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 2.0.0-alpha.3 | 61 | 8/18/2026 |
| 1.3.1 | 316 | 8/24/2023 |
| 1.3.0 | 278 | 8/22/2023 |
| 1.2.0 | 262 | 8/22/2023 |
| 1.1.0 | 261 | 8/17/2023 |