diff --git a/installer/ini.py b/installer/ini.py index 86be3341..f6024412 100644 --- a/installer/ini.py +++ b/installer/ini.py @@ -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 = ` (an alias written as its canonical code); see the module docstring.""" + """text with `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 diff --git a/tests/launcher_test.py b/tests/launcher_test.py index 597a3fe0..bcf39d12 100644 --- a/tests/launcher_test.py +++ b/tests/launcher_test.py @@ -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'