Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions I18N.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# About NextUI i18n

## The idea

NextUI ships a runtime translation layer (`workspace/all/common/i18n.c`): a
static hash table loaded from `.system/res/lang/{en,<code>}.lang`, looked up
through `T(key)`. Every core binary links `i18n.c` and calls
`I18N_init(CFG_getLanguage())` once in `GFX_init()`. `T(key)` returns the
translation, or the key itself if there's no entry — a missing translation
never crashes, it just falls back to the input.

That part is unchanged. This page covers the rest: letting a Tools pak —
compiled or shell — add its own vocabulary to the same table, without
patching NextUI's own `.lang` files and without the OS trusting a pak's
content before that pak actually runs.

## Two doors

**Compiled pak (C/C++)**: link `i18n.c` like core tools do (see any
`workspace/all/*/makefile`), then after `I18N_init()` has already run, merge
your own file:

```c
int I18N_load_extra(const char *path); // appends, never clears the table

char path[MAX_PATH];
snprintf(path, sizeof(path), "%s/res/lang/%s.lang", pak_dir, I18N_active_code());
I18N_load_extra(path);
```

Same `.lang` format as the OS (`key=value`, `#` comments, `\n`/`\t`/`\\`
escapes). From here on, call `T("mypak.title")` like core code does.

**Shell pak**: no C, so use `i18n.elf` (same family as `nextval.elf`,
already used by `MinUI.pak/launch.sh`):

```sh
MSG=$(i18n.elf get mypak.title -f "$(dirname "$0")/res/lang/$(i18n.elf lang).lang")
```

- `i18n.elf get <key> [-f <path>]...` — resolves `<key>`, merging each
`-f` file first (repeatable; later files win on collision). Falls back to
the key verbatim, same as `T()`.
- `i18n.elf lang` — prints the active language code.
- `i18n.elf format` — see interpolation below.

`i18n.elf` is a one-shot process, so `-f` costs a file parse per call —
batch your lookups rather than shelling out per string.

## Interpolated strings (`_fmt` keys)

`.lang` values can carry `printf`-style placeholders (already used by core:
`settings.ra.sync_pending_fmt=%u pending — send to RA server`). A compiled
pak resolves these like core does: `snprintf(buf, sizeof(buf),
T("mypak.count_fmt"), n)`.

A shell pak must **not** pipe `i18n.elf get` into `printf(1)` — the
template can come from a translation you don't control (see community packs
below), and handing an untrusted string to `printf` as its own format
argument is the classic format-string footgun. Use `i18n.elf format`
instead:

```sh
MSG=$(i18n.elf format mypak.count_fmt -f "$LANG_FILE" -- "$COUNT")
```

It substitutes **`%s` only**, positionally, via a small in-process scanner —
never handed to libc's `printf`/`vprintf`. A missing arg becomes empty; any
other specifier (`%d`, `%u`, `%n`...) is left as literal text, never
interpreted. Consequence: a `_fmt` key meant for a shell pak must only use
`%s` — format numbers to text yourself before passing them in. Compiled
paks aren't bound by this; their own `snprintf` controls the types.

## Where your files live

```
Tools/<platform>/MyPak.pak/
launch.sh
res/lang/
en.lang # required
fr.lang # optional
```

Only `en.lang` is required; missing keys in a locale file fall through to
`en.lang` then to the OS's own table, same chain the OS uses for itself.

There's deliberately **no directory the OS scans on its own** (unlike
`pak-hooks.sh`'s auto-discovery) — a pak pulls its own vocabulary in, on
demand, only while running:

- removing the pak removes its strings with it, nothing orphaned;
- an installed-but-never-opened pak costs the table nothing;
- the OS never merges third-party text without that pak asking for it.

## Naming your keys

One flat namespace shared by the OS and every pak that merges into it.
Prefix your keys with a short tag unique to your pak — the convention
already used in NextUI's own `en.lang` (`sg.*` = ScrapeGoat, `ps.*` = Pak
Store):

```
sg.menu.browse=Browse
sg.btn.install=Install
```

Not enforced in code — a collision silently lets one value win. Pick a tag
nobody else plausibly picked.

## Rules

- Ship `en.lang` with your pak; it's your fallback of last resort.
- Load your extra file once, early — a compiled pak's `main()`, or once per
`launch.sh` run for a shell pak.
- Never write into `.system/res/lang/*.lang` — those are core files.
- `I18N_load_extra()` only appends; safe to call more than once (e.g. your
own file, then a dependency's) without clobbering the OS's entries.

## Third-party translation packs

A *separate* pak can supply translations for someone else's pak — on one
condition: the target pak must already route its strings through
`T()`/`i18n.elf`, even if it only ever shipped `en.lang`. This mechanism
only resolves keys a pak explicitly asks for; it can't intercept hardcoded
literals. A pak that hasn't been made key-based needs that small patch first
(replace literals with keys + ship `en.lang`) before anyone can translate
it. Matching on literal English text instead of a key was considered and
rejected — it breaks the safe-fallback/namespacing model and risks
translating the same English word wrong depending on context.

For an already key-based pak missing a locale, a translation pak fills the
gap without touching its code, via one well-known shared path passed as an
extra `-f`:

```sh
# inside TargetPak.pak/launch.sh — one extra -f, nothing else changes
COMMUNITY="$SDCARD_PATH/.userdata/shared/i18n-community/TargetPak.pak/$(i18n.elf lang).lang"
MSG=$(i18n.elf get target.title -f "$LANG_FILE" -f "$COMMUNITY")
```

A `pak-i18n-common`-style pak is then pure data, no runtime component: it
ships `<TargetPak.pak>/<code>.lang` fragments and drops them into that
shared directory once via a `boot.sh` hook (see `HOOKS.md`). A `boot.sh`
that copies won't retroactively clean up on removal — symlink instead if the
platform allows it, or document that removal leaves copies behind.

Covering a "top 10" of popular paks this way is really two jobs: getting
the key-ification patch merged upstream in each pak that doesn't have one
yet (the actual bottleneck), then bundling `.lang` fragments once that's
done. The community pak only ever solves the second half.

## Examples

```sh
#!/bin/sh
# MyPak.pak/launch.sh
PAK_DIR="$(dirname "$0")"
LANG_FILE="$PAK_DIR/res/lang/$(i18n.elf lang).lang"
TITLE=$(i18n.elf get mypak.title -f "$LANG_FILE")
echo "$TITLE"
```

```c
#include "i18n.h"
#include "config.h"

int main(int argc, char *argv[]) {
CFG_init(NULL, NULL);
I18N_init(CFG_getLanguage());

char path[MAX_PATH];
snprintf(path, sizeof(path), "%s/res/lang/%s.lang",
SDCARD_PATH "/Tools/" PLATFORM "/MyPak.pak", I18N_active_code());
I18N_load_extra(path);

puts(T("mypak.title"));
return 0;
}
```
1 change: 1 addition & 0 deletions makefile
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ endif
cp ./workspace/all/nextui/build/$(PLATFORM)/nextui.elf ./build/SYSTEM/$(PLATFORM)/bin/
cp ./workspace/all/minarch/build/$(PLATFORM)/minarch.elf ./build/SYSTEM/$(PLATFORM)/bin/
cp ./workspace/all/nextval/build/$(PLATFORM)/nextval.elf ./build/SYSTEM/$(PLATFORM)/bin/
cp ./workspace/all/i18n_cli/build/$(PLATFORM)/i18n.elf ./build/SYSTEM/$(PLATFORM)/bin/
cp ./workspace/all/clock/build/$(PLATFORM)/clock.elf ./build/EXTRAS/Tools/$(PLATFORM)/Clock.pak/
cp ./workspace/all/minput/build/$(PLATFORM)/minput.elf ./build/EXTRAS/Tools/$(PLATFORM)/Input.pak/
cp ./workspace/all/settings/build/$(PLATFORM)/settings.elf ./build/EXTRAS/Tools/$(PLATFORM)/Settings.pak/
Expand Down
5 changes: 5 additions & 0 deletions workspace/all/common/i18n.c
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,11 @@ int I18N_reload(const char *lang_code) {
return load_lang(lang_code);
}

int I18N_load_extra(const char *path) {
if (!s_inited || !path) return 0;
return parse_file(path);
}

void I18N_quit(void) {
table_clear();
s_active_code[0] = '\0';
Expand Down
7 changes: 7 additions & 0 deletions workspace/all/common/i18n.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ int I18N_reload(const char *lang_code);
char *I18N_t(const char *key);
const char *I18N_active_code(void);

// Merges an additional .lang file into the table already loaded by
// I18N_init()/I18N_reload(), without clearing it first -- lets a pak add
// its own keys (or a community translation override another pak's) on top
// of the OS vocabulary. Safe to call more than once; later files win on
// key collisions. No-op (returns 0) if called before I18N_init().
int I18N_load_extra(const char *path);

#define T(k) I18N_t(k)

#ifdef __cplusplus
Expand Down
74 changes: 74 additions & 0 deletions workspace/all/i18n_cli/i18n.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include "defines.h"
#include "config.h"
#include "i18n.h"

void printUsage(void)
{
printf("usage: i18n get <key> [-f <path>]...\n"
" i18n format <key> [-f <path>]... [--] <arg>...\n"
" i18n lang\n");
}

// %s-only, positional, never handed to printf(3)/vprintf: a .lang value
// (possibly a third-party or community-contributed translation, see
// I18N.md) can never control how many bytes get written or trigger
// undefined behavior. Any other specifier (%d, %u, %n, ...) is left as
// literal text, never interpreted.
static void format_s(const char *tmpl, char **args, int nargs)
{
int ai = 0;
for (const char *p = tmpl; *p; ) {
if (p[0] == '%' && p[1] == 's') {
if (ai < nargs) fputs(args[ai++], stdout);
p += 2;
} else {
fputc(*p++, stdout);
}
}
fputc('\n', stdout);
}

int main(int argc, char *argv[])
{
CFG_init(NULL, NULL);
I18N_init(CFG_getLanguage());

if (argc >= 2 && strcmp(argv[1], "-h") == 0) {
printUsage();
return EXIT_SUCCESS;
}

if (argc >= 2 && strcmp(argv[1], "lang") == 0) {
printf("%s\n", I18N_active_code());
return EXIT_SUCCESS;
}

int is_get = (argc >= 2 && strcmp(argv[1], "get") == 0);
int is_format = (argc >= 2 && strcmp(argv[1], "format") == 0);

if ((is_get || is_format) && argc >= 3) {
const char *key = argv[2];

int i = 3;
for (; i + 1 < argc && strcmp(argv[i], "-f") == 0; i += 2)
I18N_load_extra(argv[i + 1]);

if (is_get) {
printf("%s\n", T(key));
return EXIT_SUCCESS;
}

// format: everything left, after an optional "--", is %s args
if (i < argc && strcmp(argv[i], "--") == 0) i++;
format_s(T(key), &argv[i], argc - i);
return EXIT_SUCCESS;
}

printf("Error: invalid arguments\n");
printUsage();
return EXIT_FAILURE;
}
35 changes: 35 additions & 0 deletions workspace/all/i18n_cli/makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
###########################################################

ifeq (,$(PLATFORM))
PLATFORM=$(UNION_PLATFORM)
endif

ifeq (,$(PLATFORM))
$(error please specify PLATFORM, eg. PLATFORM=trimui make)
endif

ifeq (,$(CROSS_COMPILE))
$(error missing CROSS_COMPILE for this toolchain)
endif

###########################################################

include ../../$(PLATFORM)/platform/makefile.env

###########################################################

TARGET = i18n
INCDIR = -I. -I../common/ -I../../$(PLATFORM)/platform/
SOURCE = $(TARGET).c ../common/utils.c ../common/config.c ../common/i18n.c

CC = $(CROSS_COMPILE)gcc
CFLAGS += $(OPT)
CFLAGS += $(INCDIR) -DPLATFORM=\"$(PLATFORM)\" -std=gnu99

PRODUCT= build/$(PLATFORM)/$(TARGET).elf

all:
mkdir -p build/$(PLATFORM)
$(CC) $(SOURCE) -o $(PRODUCT) $(CFLAGS) $(LDFLAGS)
clean:
rm -f $(PRODUCT)
3 changes: 3 additions & 0 deletions workspace/makefile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ ifeq ($(PLATFORM), desktop)
#cd ./all/gametime/ && make
cd ./all/minput/ && make
cd ./all/nextval/ && make
cd ./all/i18n_cli/ && make
cd ./all/settings/ && make
else
ifeq ($(PLATFORM), tg5040)
Expand All @@ -50,6 +51,7 @@ endif
cd ./all/minput/ && make
cd ./all/syncsettings/ && make
cd ./all/nextval/ && make
cd ./all/i18n_cli/ && make
cd ./all/settings/ && make
cd ./all/ledcontrol/ && make
cd ./all/bootlogo/ && make
Expand Down Expand Up @@ -97,6 +99,7 @@ endif
cd ./all/gametime/ && make clean
cd ./all/minput/ && make clean
cd ./all/nextval/ && make clean
cd ./all/i18n_cli/ && make clean
cd ./all/settings/ && make clean
cd ./all/audiomon/ && make clean
cd ./$(PLATFORM) && make clean