Localization¶
BIOS language detection (English / Japanese) and bilingual NGPC games: string tables, asset selection, system font behavior, and pitfalls.
1. Overview¶
The Neo Geo Pocket Color (and the monochrome NGP) lets the user select their language in the BIOS menu: English or Japanese.
Games designed for bilingual support adapt automatically — menus, text strings, and certain visual assets change based on the system language.
Detection requires no BIOS call — it is a simple memory read at address 0x6F87,
written by the BIOS at startup.
Read this register once during init and cache it in a static
variable, accessible via ngpc_get_language().
2. Hardware Register — HW_LANGUAGE¶
Address: 0x6F87
Size: u8 (8 bits)
Access: read-only (set by BIOS at boot, never changes during gameplay)
This register is part of the BIOS System Information area (0x6F80-0x6F91):
0x6F80 HW_BAT_VOLT_RAW battery voltage (u16)
0x6F82 HW_JOYPAD joypad state
0x6F84 HW_USR_BOOT boot type (0=power, 1=resume, 2=alarm)
0x6F85 HW_USR_SHUTDOWN BIOS shutdown request flag
0x6F86 HW_USR_ANSWER user response flags
0x6F87 HW_LANGUAGE language (0=EN, 1=JP) <-- here
0x6F91 HW_OS_VERSION 0=NGP mono, !=0=NGPC color
Definition in ngpc_hw.h:
#define HW_LANGUAGE (*(volatile u8 *)0x6F87)
#define LANG_JAPANESE 0u /* HW_LANGUAGE == 0 -- SysWork.txt order */
#define LANG_ENGLISH 1u /* HW_LANGUAGE == 1 */
Direct read is possible but prefer the cached API:
3. Language API¶
3.1 Implementation¶
The language constants (LANG_ENGLISH / LANG_JAPANESE) live in ngpc_hw.h, and
ngpc_get_language() returns the value cached during initialization:
static u8 s_language = 0; /* cached at init */
void ngpc_init(void)
{
/* ... */
/* Cache system language from BIOS register (0x6F87).
* LANG_ENGLISH=0, LANG_JAPANESE=1. Set by BIOS at boot, read-only. */
s_language = HW_LANGUAGE;
/* ... */
}
u8 ngpc_get_language(void)
{
return s_language;
}
Basic usage:
#include "ngpc_sys.h"
#include "ngpc_hw.h" /* for LANG_ENGLISH / LANG_JAPANESE */
if (ngpc_get_language() == LANG_JAPANESE) {
/* Japanese text / assets */
} else {
/* English text / assets (default) */
}
3.2 Why Cache Instead of Reading Directly?¶
Same rationale as ngpc_is_color():
- Avoids scattered
volatilereads throughout game code - The value never changes during gameplay
- Single source of truth
- Cost: zero (one
u8in static RAM)
4. Sysfont and Language¶
ngpc_load_sysfont() (BIOS call BIOS_SYSFONTSET) loads the system font
into VRAM tile slots 0..255, indexed by character code.
⛔ Corrected 2026-09-23. This section used to claim the font DIFFERS with the active language, and that it occupies slots 32-127. Both are wrong. Official source (
SysCall.txt,VECT_SYSFONTSET): the call "transfers 256 system font characters to the front half of the character RAM (0xA000-0xAFFF)". Measured by running a retail BIOS on the same ROM with the console set to English and then to Japanese: character RAM is byte-identical in both cases, and it contains ASCII and katakana at once.The practical consequence is the opposite of what this page used to say: katakana cost no extra art and no extra ROM, and they do not require a Japanese console. They are already there.
4.1 ASCII range¶
Slots 0x20-0x7F = ASCII glyphs (space, punctuation, A-Z, a-z, 0-9).
The slot index equals the ASCII character code:
ngpc_text_print() writes ASCII values directly into the tilemap — this works because
tile index == ASCII code.
4.2 Katakana range¶
Slots 0xA1-0xDF = the 64 half-width Katakana, loaded by the same call, at
the same time as the ASCII glyphs — not "instead of" them.
Standard half-width Katakana encoding (JIS X 0201):
0xA1 = small "a" katakana
0xA2 = "a" katakana
0xA3 = small "i" katakana
...
0xBF = "tsu" katakana
0xC4 = "na" katakana
...
0xDF = dakuten (voiced mark)
To display Katakana, ngpc_text_print() must receive a const char* containing
bytes in the range 0xA1-0xDF.
Important: in cc900, string literals use signed char. Bytes > 0x7F must be placed
in a u8 array or cast explicitly:
/* Katakana "START" — "S-TA-A-TO" in half-width Katakana */
static const u8 jp_start[] = { 0xBD, 0xC0, 0xC3, 0xC4, 0x00 };
ngpc_text_print(GFX_SCR1, 0, 5, 10, (const char *)jp_start);
Basic ASCII characters (A-Z, 0-9) remain displayable in Japanese mode — the BIOS preserves ASCII glyphs in the lower portion of the font range (slots ~32-95). Verify on target hardware if mixing ASCII and Katakana.
5. Bilingual String Table Pattern¶
Recommended pattern for managing text in both languages: a pointer table indexed
by ngpc_get_language() (0 or 1).
5.1 Basic Pattern¶
/* String tables — English [0], Japanese [1] */
static const char * const s_msg_title[2] = {
"NGPC Game", /* [0] LANG_ENGLISH */
"NGPC Game" /* [1] LANG_JAPANESE (same title here) */
};
static const char * const s_msg_start[2] = {
"Press A to start", /* [0] LANG_ENGLISH */
"A: START" /* [1] LANG_JAPANESE */
};
static const char * const s_msg_pause[2] = {
"PAUSED", /* [0] LANG_ENGLISH */
"PAUSE" /* [1] LANG_JAPANESE */
};
/* Usage: */
u8 lang = ngpc_get_language(); /* 0 or 1 */
ngpc_text_print(GFX_SCR1, 0, col, row, s_msg_title[lang]);
ngpc_text_print(GFX_SCR1, 0, col, row, s_msg_start[lang]);
Advantages:
- Zero overhead — lang is a u8; [lang] indexing = 1 instruction
- Extensible — adding a language means adding a column
- Readable — both versions are side-by-side in the source
- Safe — no switch/if, no missing branch
- cc900-compatible — const char * const [2] tables work correctly
5.2 Organization for a Full Game¶
Group strings by screen or module:
/* strings_title.c */
const char * const g_str_title_name[2] = { "MY GAME", "MY GAME" };
const char * const g_str_title_start[2] = { "Press A", "A BUTTON" };
const char * const g_str_title_opt[2] = { "Options", "OPTIONS" };
/* strings_game.c */
const char * const g_str_game_score[2] = { "SCORE", "SCORE" };
const char * const g_str_game_life[2] = { "LIFE", "LIFE" };
const char * const g_str_game_over[2] = { "GAME OVER", "GAME OVER"};
/* strings_menu.c */
const char * const g_str_yes[2] = { "YES", "YES" };
const char * const g_str_no[2] = { "NO", "NO" };
/* strings.h — extern declarations */
extern const char * const g_str_title_name[2];
extern const char * const g_str_title_start[2];
/* ... */
Usage throughout the codebase:
#include "strings.h"
u8 lang = ngpc_get_language();
ngpc_text_print(GFX_SCR1, 0, 5, 4, g_str_title_name[lang]);
5.3 Global Language Cache¶
For larger games, avoid passing lang as a parameter everywhere:
/* game_state.h */
extern u8 g_lang; /* 0=EN, 1=JP — copy of ngpc_get_language() */
/* main.c — after ngpc_init() */
u8 g_lang;
void main(void) {
ngpc_init();
g_lang = ngpc_get_language(); /* cache once, use everywhere */
/* ... */
}
/* Anywhere in the game: */
ngpc_text_print(GFX_SCR1, 0, col, row, s_msg_start[g_lang]);
6. Advanced Patterns¶
6.1 Language-Dependent Asset Selection¶
Not just strings — some games ship different tiles or sprites per language (logo tiles with embedded text, etc.):
/* Tile data pointers indexed by language */
/* IMPORTANT: NGP_FAR required for cartridge ROM data at 0x200000+ */
static const u16 NGP_FAR * const s_logo_tiles[2] = {
logo_en_tiles, /* [0] LANG_ENGLISH — defined in assets_en.c */
logo_jp_tiles /* [1] LANG_JAPANESE — defined in assets_jp.c */
};
static const u16 s_logo_tile_count[2] = {
LOGO_EN_TILE_COUNT,
LOGO_JP_TILE_COUNT
};
void title_init(void)
{
u8 lang = ngpc_get_language();
ngpc_gfx_load_tiles_at(s_logo_tiles[lang], s_logo_tile_count[lang], 128);
}
NGP_FAR reminder: cartridge ROM data lives at
0x200000+, out of reach for cc900's default 16-bit near pointers. Always useNGP_FARfor pointers to ROM tiles, palettes, and tilemaps.
6.2 Language and Save Data¶
General rule: do not save the language. Read it from the BIOS on every boot. If the user changes the language in the BIOS menu, the game must adapt automatically. Saving the language would create a stale override.
Exception: if the game provides an in-game language override in its Options screen, then saving the choice makes sense:
typedef struct {
u8 magic[4];
u8 lang_override; /* 0xFF = use BIOS, 0=EN, 1=JP */
/* ... */
} SaveData;
/* At init: */
u8 lang;
if (save.lang_override == 0xFF) {
lang = ngpc_get_language(); /* use BIOS default */
} else {
lang = save.lang_override; /* user choice */
}
6.3 Inline Condition (No Table)¶
For occasional one-off strings, a direct conditional is acceptable:
/* Simple, readable, zero overhead on TLCS-900H */
const char *confirm = (ngpc_get_language() == LANG_JAPANESE)
? "A: KETTEI" /* approximate ASCII for Japanese context */
: "A: Confirm";
/* Or explicit branch: */
if (ngpc_get_language() == LANG_JAPANESE) {
ngpc_text_print(GFX_SCR1, 0, 2, 5, "A: KETTEI");
} else {
ngpc_text_print(GFX_SCR1, 0, 2, 5, "A: Confirm");
}
Use the string table pattern (§5) for anything more than a few strings.
6.4 Accented and special glyphs for European languages¶
The BIOS font has no accents. A game localised into Spanish, Portuguese and German drew its
own: Ñ ¿ ¡ Á É Í Ó Ú in characters 0x18..0x1F, then À Â Ê Ô Ã Õ Ç in 0x10..0x16, all
loaded every time the dialogue screen opens, after the font — during a race those same
characters hold roadside art. Only the dialogue box uses them; the race HUD stays plain ASCII
(a word is written without its accent there).
- One table maps each character to its code, shared by the glyph generator and the text
generator; C literals use octal escapes (
"\030"), never raw bytes. - ⛔ An accent one pixel above its letter, touching it, is invisible ("GANALE" read for "GÁNALE"). Keep an empty row between accent and letter: the vowel gets 5 rows.
- Text lives in a source file (one box per line, speaker first) and a generator writes the table between markers in the C file, refusing a line whose speaker is shifted. Never edit the generated table.
- Keep the language choice in a field of the SAVE (a field in a 512-byte buffer costs no RAM), offer it on a flags screen after the logo, and when the grid of flags has holes, move the cursor to the nearest lit flag, not "same column or nothing" (one language was unreachable).
- ⛔ If the profile is reloaded after the flag screen (menu init reading the save), it overwrites the choice just made: keep the choice across that load and re-apply it.
- Benches reading text back from RAM: an empty cell and a custom glyph can both read as the same filler character — strip it on both sides before comparing.
Adapting, not translating: each line must make sense on its own in the target language; gender-neutral phrasing for the player where the language forces agreement (adverbs instead of adjectives, no inclusive "we" that genders the player).
7. Pitfalls and Gotchas¶
Pitfall 1 — Reading HW_LANGUAGE Before ngpc_init()¶
runtime_bootstrap() (called inside ngpc_init()) copies initialized data from ROM to RAM
and zeroes BSS. Static variables initialized to 0 are in BSS and are zeroed by this step.
s_language = HW_LANGUAGE is set after runtime_bootstrap() in ngpc_init(),
so the cache is safe. Do not read HW_LANGUAGE from user code before ngpc_init() completes.
Recommended initialization order:
ngpc_init(); /* 1. reads and caches HW_LANGUAGE into s_language */
ngpc_load_sysfont(); /* 2. loads the font matching the current language */
/* 3. ngpc_get_language() and font tiles are now coherent */
Pitfall 2 — String Literals with Bytes > 0x7F¶
/* BAD: cc900 treats char as signed — 0xA2 = -94, may generate warnings */
const char *jp = "\xa2\xb3";
/* GOOD: use u8[] and cast */
static const u8 jp_text[] = { 0xA2, 0xB3, 0x00 };
ngpc_text_print(GFX_SCR1, 0, col, row, (const char *)jp_text);
Pitfall 3 — Assuming Language Index Is Always 0 or 1¶
The hardware guarantees 0 or 1, but a buggy emulator or corrupted ROM could return
another value. Indexing a [2] array with a value > 1 causes an out-of-bounds read.
Optional defensive clamp (useful in debug builds):
u8 ngpc_get_language(void)
{
/* Clamp to maximum 1 to never overrun a [2] table */
return (s_language <= LANG_JAPANESE) ? s_language : LANG_ENGLISH;
}
A minimal build may omit this clamp by default, but it is a valid option for extra safety.
Pitfall 4 — Calling ngpc_load_sysfont() Before Language Is Determined¶
ngpc_load_sysfont() loads a font adapted to the current language.
As long as ngpc_init() has been called first (which caches HW_LANGUAGE), the value is
already correct. No ordering problem in practice — just follow the standard init sequence
described in Pitfall 1.
Quick Reference¶
| Item | Value / File | Notes |
|---|---|---|
| Hardware address | 0x6F87 |
Part of BIOS system area |
| Register macro | HW_LANGUAGE |
ngpc_hw.h |
| English constant | LANG_ENGLISH = 0u |
ngpc_hw.h |
| Japanese constant | LANG_JAPANESE = 1u |
ngpc_hw.h |
| Cached API | ngpc_get_language() |
Returns cached value |
| Cache init | s_language = HW_LANGUAGE in ngpc_init() |
After runtime_bootstrap |
| Sysfont, whole set | 256 characters in tile slots 0..255 | Tile index == character code; language-independent |
| Sysfont, ASCII | 0x20-0x7F at the same slot numbers |
Tile index == ASCII code |
| Sysfont, katakana | 0xA1-0xDF at the same slot numbers |
JIS X 0201, loaded by the same call |
| Japanese bytes | Use u8[] + (const char *) cast |
Signed char issue in cc900 |
| String table | const char * const msg[2] = { "EN", "JP" }; |
Index by ngpc_get_language() |
| Asset table | const u16 NGP_FAR * const tiles[2] |
NGP_FAR required for ROM data |
| Save language? | No — read from BIOS each boot | Unless in-game override needed |
| Out-of-bounds guard | Clamp to 1: (lang <= 1) ? lang : 0 |
Optional, defensive |
Minimal bilingual ROM pattern:
/* After ngpc_init() + ngpc_load_sysfont(): */
u8 lang = ngpc_get_language(); /* 0=EN, 1=JP */
static const char * const s_msg[2] = { "Press A", "A BUTTON" };
ngpc_text_print(GFX_SCR1, 0, col, row, s_msg[lang]);
See Also¶
- Hardware Registers — Full BIOS system area map (
0x6F80-0x6F91) - BIOS —
BIOS_SYSFONTSET, system font loading - Effects and Raster —
ngpc_text_print()API, tile slot layout, sysfont usage - Storage and Saves — Flash save, saving language override in SaveData
- Game Loop —
ngpc_init()sequence, runtime_bootstrap, init order