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>
This commit is contained in:
chrg6
2026-11-10 15:14:42 -05:00
parent caf50ecf1c
commit 11050fb0d3
2 changed files with 225 additions and 19 deletions

View File

@@ -3,7 +3,8 @@
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.
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
@@ -21,6 +22,7 @@ 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):
@@ -29,27 +31,43 @@ def canonical_language(lang):
def set_language(text, lang):
"""text with `lang = <lang>` (an alias written as its canonical code); see the module docstring."""
"""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)
lines = text.splitlines()
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):
m = ACTIVE.match(line)
body, end = split(line)
m = ACTIVE.match(body)
if m:
lines[i] = f'{m.group(1)}lang{m.group(2)}{lang}'
return '\n'.join(lines) + '\n'
lines[i] = f'{m.group(1)}lang{m.group(2)}{lang}{end}'
return bom + ''.join(lines)
for i, line in enumerate(lines):
if COMMENTED.match(line):
lines[i] = f'lang = {lang}'
return '\n'.join(lines) + '\n'
body, end = split(line)
if COMMENTED.match(body):
lines[i] = f'lang = {lang}{end}'
return bom + ''.join(lines)
for i, line in enumerate(lines):
m = SECTION.match(line)
body, end = split(line)
m = SECTION.match(body)
if m and m.group(1).strip().lower() == 'language':
lines.insert(i + 1, f'lang = {lang}')
return '\n'.join(lines) + '\n'
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('')
lines += ['[language]', f'lang = {lang}']
return '\n'.join(lines) + '\n'
lines.append(nl)
lines += [f'[language]{nl}', f'lang = {lang}{nl}']
return bom + ''.join(lines)
def get_language(text):
@@ -60,19 +78,33 @@ def get_language(text):
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 its other lines. Returns the path."""
"""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 = path.read_text(encoding='utf-8-sig')
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 path.is_file() and new == text:
if new == old:
return path
tmp = path.with_name('nx2navi.ini.tmp')
tmp.write_text(new, encoding='utf-8', newline='\n')
with open(tmp, 'w', encoding='utf-8', errors='surrogateescape', newline='') as f:
f.write(new)
os.replace(tmp, path)
return path

View File

@@ -262,6 +262,50 @@ class VersionTest(unittest.TestCase):
versions.parse('banana')
# A player's settings file as a report showed it (an AMD card, 0.7.1): what the game's menu wrote, three lines added by
# hand that are no settings of the game (async_compute, vram_safe_mode, exception_handler: the player took the first for
# the fix of a crash and asked for it as a default, because "the configuration file gets overwritten on updates"), and a
# comment. Saved as an editor on Windows saves it: CRLF line ends, no line end after the last line. Nothing in an
# update may change a byte of it (PlayerIniTest, BackgroundUpdateTest and MakeGameTest each run their part of one).
PLAYER_INI = (
b'# my settings\r\n'
b'[display]\r\n'
b'async_compute = 1\r\n'
b'fullscreen = 1\r\n'
b'window_size = 1920x1080\r\n'
b'gpu_index = 0\r\n'
b'perf_overlay = fps\r\n'
b'vk_present = mailbox\r\n'
b'upscaler = bilinear\r\n'
b'dlaa = 1\r\n'
b'framegen = off\r\n'
b'taa = fsr3\r\n'
b'hdr = 0\r\n'
b'aspect = fit\r\n'
b'game_res = 1800\r\n'
b'sharpness = 80\r\n'
b'aniso = 2\r\n'
b'assume_focus = 1\r\n'
b'texture_detail = auto\r\n'
b'\r\n'
b'[input]\r\n'
b'bind_a = key:default, pad:south\r\n'
b'bind_b = key:default, pad:east\r\n'
b'bind_x = key:default, pad:north\r\n'
b'bind_y = key:default, pad:west\r\n'
b'\r\n'
b'[language]\r\n'
b'operation_mode = docked\r\n'
b'\r\n'
b'[performance]\r\n'
b'wb_lazy = 0\r\n'
b'vram_safe_mode = 1\r\n'
b'exception_handler = 1\r\n'
b'\r\n'
b'[frame rate]\r\n'
b'fps_limit = 120')
class GameIniTest(Tmp):
"""The game settings the launcher switches (launcher/gameini.py), read as runtime/cfg.c reads nx2navi.ini."""
@@ -305,6 +349,45 @@ class GameIniTest(Tmp):
gameini.put(self.tmp, 'discord', 'discord_rpc', '1')
self.assertTrue((self.tmp / 'nx2navi.ini').is_dir())
class PlayerIniTest(Tmp):
"""The settings file of a player (PLAYER_INI) under the two writers outside the game: the launcher's Settings
(launcher/gameini.py) and the older installer's last step (installer/ini.py). Each changes the one line it is
for; the lines that are no settings of the game, the comment and every other byte stay."""
def test_the_launchers_setting_adds_one_line(self):
ini = self.tmp / 'nx2navi.ini'
ini.write_bytes(PLAYER_INI)
self.assertEqual(gameini.get(self.tmp, 'wb_lazy'), '0')
self.assertEqual(gameini.get(self.tmp, 'async_compute'), '1') # read like any line; the game ignores it
gameini.put(self.tmp, 'discord', 'discord_rpc', '0')
self.assertEqual(ini.read_bytes(), PLAYER_INI + b'\r\n\r\n[discord]\r\ndiscord_rpc = 0\r\n')
gameini.put(self.tmp, 'discord', 'discord_rpc', '1')
self.assertEqual(ini.read_bytes(), PLAYER_INI + b'\r\n\r\n[discord]\r\ndiscord_rpc = 1\r\n')
def test_the_older_installer_changes_the_language_line_only(self):
"""installer/ini.py sets the language, in the older installer's last step at every install and update: the
one line goes under [language] with the file's own line ends, and every other byte stays (it used to write
the whole file again with LF line ends, a line end after the last line and no byte order mark). A second run
leaves the file alone; a changed language changes that line; a byte order mark and a line that is not UTF-8
stay too."""
from installer import ini as inst_ini
path = self.tmp / 'nx2navi.ini'
path.write_bytes(PLAYER_INI)
inst_ini.write(self.tmp, 'en-US')
want = PLAYER_INI.replace(b'[language]\r\n', b'[language]\r\nlang = en-US\r\n')
self.assertEqual(path.read_bytes(), want)
self.assertEqual(want.count(b'lang = en-US'), 1)
stamp = path.stat().st_mtime_ns
inst_ini.write(self.tmp, 'en-US')
self.assertEqual((path.read_bytes(), path.stat().st_mtime_ns), (want, stamp))
inst_ini.write(self.tmp, 'de')
self.assertEqual(path.read_bytes(), want.replace(b'lang = en-US', b'lang = de'))
marked = b'\xef\xbb\xbf# caf\xe9\r\nlang = ja\r\nasync_compute = 1'
path.write_bytes(marked)
inst_ini.write(self.tmp, 'fr')
self.assertEqual(path.read_bytes(), marked.replace(b'lang = ja', b'lang = fr'))
self.assertFalse((self.tmp / 'nx2navi.ini.tmp').exists())
class RebuildArgsTest(Tmp):
"""Rebuild the Game compiles the game's code for this processor's level (--retune); an update keeps the level its
@@ -898,6 +981,28 @@ class BackgroundUpdateTest(Tmp):
self.assertEqual(bg.install(self.game, rel), '')
self.assertEqual(len(self.restarts), 1)
def test_an_update_leaves_the_games_settings_file(self):
"""The launcher's part of an update (the release staged beside the running version, the switch to it, the
cleanup after the rebuild) changes nothing in the game folder, also in its default place inside the
launcher's folder: the player's nx2navi.ini is the same bytes afterwards, and an uninstall that keeps the game
does not name it."""
game = self.layout.root / 'game'
(game / 'profile').mkdir(parents=True)
ini = game / 'nx2navi.ini'
ini.write_bytes(PLAYER_INI)
before = sorted(p.relative_to(game).as_posix() for p in game.rglob('*'))
bg = self.background(self.context())
rel = self.publish('0.5.0', runtime='py-2')
self.assertEqual(self.stage(bg, rel), 'staged')
self.assertEqual(ini.read_bytes(), PLAYER_INI)
self.assertEqual(bg.install(str(game), rel, why='the launcher started'), '0.5.0')
self.assertEqual(self.state(), ('0.5.0', '0.4.0', [['--continue-update', '0.4.0']]))
updater.prune(self.layout) # what ContinueUpdateJob ends with
self.assertEqual(ini.read_bytes(), PLAYER_INI)
self.assertEqual(sorted(p.relative_to(game).as_posix() for p in game.rglob('*')), before)
gone, _ = winsys.uninstall_plan(self.layout)
self.assertFalse([p for p in gone if p == game or p in ini.parents])
def test_no_install_while_the_game_runs(self):
"""A copy of the game started elsewhere, the copy the launcher started, and a download under way each hold
the install back, also one the player asked for."""
@@ -1635,6 +1740,75 @@ class MakeGameTest(Tmp):
self.assertEqual(build(), ([], [{'ev': 'step', 'step': 'prompts', 'state': 'skip'}], 4))
self.assertTrue((self.out / 'romfs-patches-xbox' / 'manifest.tsv').is_file())
def test_an_update_keeps_the_settings_file(self):
"""The rebuild an update ends with (make_game.py --rebuild under a newer kit: the lift, the compile, the
shaders step and the button prompts all run again) leaves the game folder's nx2navi.ini as the player has it,
byte for byte. The build reads the file (the bind_ lines for the prompts, shader_carry for the shaders step)
and never writes it; so does a second rebuild that finds nothing to do, and one from the game files again.
The lift and the compile are stand-ins here (the real ones need the game and clang); the check, the ExeFS and
RomFS steps, the shaders step and the prompts step are the real ones."""
from unittest import mock
kit = self.tmp / 'kit'
for d in ('tools', 'bin', 'recomp'):
(kit / d).mkdir(parents=True)
(kit / 'VERSION').write_text('0.7.0\n')
(kit / 'recomp' / 'gen.py').write_text('# the lifter of 0.7.0\n')
(kit / 'bin' / self.mg.XLATE_NAME).write_bytes(b'translator')
(kit / 'bin' / 'nx2navi_xlate.id').write_text('xlate 1\n')
(kit / 'tools' / 'prepare_shaders.py').write_text('print("prepare-shaders: progress build 1 1", flush=True)\n')
for name in ('prompt_art.py', 'button_icons.py'):
shutil.copy2(ROOT / 'tools' / name, kit / 'tools' / name)
(kit / 'tools' / 'pad_prompts.py').write_text(FAKE_PAD_PROMPTS)
art = kit / 'prompt-art' / 'XGamepad' / 'Default'
art.mkdir(parents=True)
(art / 'T_X_A_White.png').write_bytes(b'picture one')
ran = []
def fake(name):
def step(*args, **kw):
ran.append(name)
return step
def build(*source):
"""One run of make_game.py; the steps that ran, in order."""
buf = io.StringIO()
argv = ['make_game.py', *source, '--out', str(self.out), '--msys', str(self.msys), '--events']
with mock.patch.object(sys, 'argv', argv), contextlib.redirect_stdout(buf), \
contextlib.redirect_stderr(io.StringIO()):
self.mg.main()
events = [json.loads(ln[2:]) for ln in buf.getvalue().splitlines() if ln.startswith('@@')]
return [e['step'] for e in events if e['ev'] == 'step' and e['state'] == 'start']
sys.path.insert(0, str(self.layout.pydeps)) # the fake zbic
self.addCleanup(sys.path.remove, str(self.layout.pydeps))
ini = self.out / 'nx2navi.ini'
with mock.patch.multiple(self.mg, KIT=kit, PROMPT_PACKAGES={}, EVENTS=False, step_lift=fake('lift'),
step_compile=fake('compile'), step_scan=fake('scan')):
self.assertEqual(build('--nup', str(self.nup)),
['check', 'exefs', 'romfs', 'lift', 'compile', 'shaders', 'prompts', 'scan'])
self.assertFalse(ini.exists()) # the build makes no settings file: the game's menu does
ini.write_bytes(PLAYER_INI)
names = sorted(p.name for p in self.out.iterdir())
# the update: a kit with another lifter and another version
(kit / 'VERSION').write_text('0.7.1\n')
(kit / 'recomp' / 'gen.py').write_text('# the lifter of 0.7.1\n')
ran.clear()
steps = build('--rebuild')
self.assertEqual(steps, ['check', 'lift', 'compile', 'shaders', 'prompts']) # the bind_ lines are new to it
self.assertEqual(ran, ['lift', 'compile'])
self.assertEqual(ini.read_bytes(), PLAYER_INI)
self.assertEqual(sorted(p.name for p in self.out.iterdir()), names) # no copy, no backup, no temporary file
self.assertEqual(json.loads((self.out / 'build-cache' / 'state.json').read_text())['kit'], '0.7.1')
self.assertEqual(build('--rebuild'), ['check']) # nothing to do
self.assertEqual(ini.read_bytes(), PLAYER_INI)
# from the game files again into the same folder (Rebuild the Game without a build to start from)
st = json.loads((self.out / 'build-cache' / 'state.json').read_text())
st['steps'] = {}
(self.out / 'build-cache' / 'state.json').write_text(json.dumps(st))
self.assertEqual(build('--nup', str(self.nup)),
['check', 'exefs', 'romfs', 'lift', 'compile', 'shaders', 'prompts', 'scan'])
self.assertEqual(ini.read_bytes(), PLAYER_INI)
@unittest.skipIf(os.name == 'nt', 'uses a POSIX script as the compiler')
def test_largest_source_compiles_first(self):
src, obj = self.tmp / 'src', self.tmp / 'obj'