mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user