Files
NX2navi/installer/ini.py
chrg6 11050fb0d3 Test that an update leaves a player's nx2navi.ini alone, and keep its bytes when the older installer sets the language.
A player wrote that the settings file is overwritten on updates and that a line has to be added again each time. Nothing an update of the launcher's install does writes the file: the updater works in the launcher's own folders, and the build only reads it (the bind_ lines for the button prompts, shader_carry for the shaders step). The tests now hold that with the player's file as reported: settings the menu wrote, three lines that are no settings of the game, a comment, CRLF line ends and no line end after the last line. It comes through the launcher's part of an update (staging, the switch, the cleanup; BackgroundUpdateTest), through the rebuild under a newer kit, a rebuild with nothing to do and a build from the game files again (MakeGameTest, with the check, the ExeFS, RomFS, shaders and prompts steps real and the lift and compile as stand-ins), and through the launcher's own settings switch, which adds its one line (PlayerIniTest).

One writer did write the whole file again: installer/ini.py, the older installer's last step at every install and update. It kept every line, but turned CRLF into LF, added a line end after the last line and dropped a byte order mark, and a file saved as UTF-16 stopped the step with an error. It now changes the lang line only and keeps every other byte; a UTF-16 file is read as that and written as UTF-8, as the game's menu writes it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 15:14:42 -05:00

111 lines
4.7 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""nx2navi.ini: set the game language and keep everything else (keys and syntax as runtime/cfg.c reads them).
A new file starts from nx2navi.ini.example, whose lines are all commented defaults. The language goes into `lang`
(a Switch language code, runtime/ov_system.c): an active `lang = ...` line is rewritten in place, else the commented
`# lang = ...` line of the example is activated, else the line is added to [language] (created at the end if
missing). Other lines, comments and line order stay as they are, byte for byte: the file's byte order mark, its line
ends and a last line without one are kept, so a player's own lines come through an install or an update unchanged.
"""
import os
import re
from pathlib import Path
# runtime/ov_system.c: the codes NX2NAVI_LANG offers. Each language appears once, under the code the game accepts.
LANGUAGES = ('ja', 'en-US', 'en-GB', 'fr', 'fr-CA', 'de', 'it', 'es', 'es-419', 'nl', 'pt', 'pt-BR', 'ru', 'ko',
'zh-Hans', 'zh-Hant')
# Older installer builds wrote these two codes; they are read as the canonical ones and rewritten on the next install.
ALIASES = {'zh-CN': 'zh-Hans', 'zh-TW': 'zh-Hant'}
# Their text falls back to the Switch shared fonts (runtime/ov_system.c), which the fonts step extracts from the
# firmware folder when the player gives one.
NEED_SHARED_FONTS = ('ko', 'zh-Hans', 'zh-Hant')
ACTIVE = re.compile(r'^(\s*)(?:nx2navi_)?lang(\s*=\s*)(.*?)(\s*)$', re.IGNORECASE)
COMMENTED = re.compile(r'^\s*[#;]\s*(?:nx2navi_)?lang\s*=.*$', re.IGNORECASE)
SECTION = re.compile(r'^\s*\[(.*)\]\s*$')
BOM = ''
def canonical_language(lang):
"""The code LANGUAGES lists for lang: an alias of an older installer build is mapped, anything else is kept."""
return ALIASES.get(lang, lang)
def set_language(text, lang):
"""text with `lang = <lang>` (an alias written as its canonical code); see the module docstring. Only that line
changes: a byte order mark, each line's own ending (LF or CRLF) and a last line without one stay as they are, and
a line that is added ends as the file's first line does."""
lang = canonical_language(lang)
bom = BOM if text.startswith(BOM) else ''
lines = text[len(bom):].splitlines(keepends=True)
nl = '\r\n' if lines and lines[0].endswith('\r\n') else '\n'
def split(line):
body = line.rstrip('\r\n')
return body, line[len(body):]
for i, line in enumerate(lines):
body, end = split(line)
m = ACTIVE.match(body)
if m:
lines[i] = f'{m.group(1)}lang{m.group(2)}{lang}{end}'
return bom + ''.join(lines)
for i, line in enumerate(lines):
body, end = split(line)
if COMMENTED.match(body):
lines[i] = f'lang = {lang}{end}'
return bom + ''.join(lines)
for i, line in enumerate(lines):
body, end = split(line)
m = SECTION.match(body)
if m and m.group(1).strip().lower() == 'language':
if not end: # the header is the file's last line, without an ending
lines[i] = body + nl
lines.insert(i + 1, f'lang = {lang}{nl}')
return bom + ''.join(lines)
if lines and not split(lines[-1])[1]:
lines[-1] += nl
if lines and lines[-1].strip():
lines.append(nl)
lines += [f'[language]{nl}', f'lang = {lang}{nl}']
return bom + ''.join(lines)
def get_language(text):
for line in text.splitlines():
m = ACTIVE.match(line)
if m:
return m.group(3)
return None
def read(path):
"""The file's text with its byte order mark and its own line ends. Bytes that are not UTF-8 come back as they are
(surrogate escapes), so a line in another encoding survives a rewrite. A file saved as UTF-16 (an editor's
"Unicode", which the game reads too: runtime/cfg.c utf16_text) is decoded as that and written back as UTF-8, as
the game's own menu writes it."""
raw = Path(path).read_bytes()
if raw[:2] in (b'\xff\xfe', b'\xfe\xff'):
return raw.decode('utf-16', errors='replace')
return raw.decode('utf-8', errors='surrogateescape')
def write(dest, lang, example=None):
"""Write dest/nx2navi.ini with lang; an existing file keeps every other line byte for byte (set_language).
Returns the path."""
path = Path(dest) / 'nx2navi.ini'
old = None
if path.is_file():
text = old = read(path)
elif example and Path(example).is_file():
text = Path(example).read_text(encoding='utf-8')
else:
text = ''
new = set_language(text, lang)
if new == old:
return path
tmp = path.with_name('nx2navi.ini.tmp')
with open(tmp, 'w', encoding='utf-8', errors='surrogateescape', newline='') as f:
f.write(new)
os.replace(tmp, path)
return path