diff --git a/launcher/ui/app.py b/launcher/ui/app.py index 84f6009f..8a6bf5b9 100644 --- a/launcher/ui/app.py +++ b/launcher/ui/app.py @@ -127,6 +127,31 @@ def exit_code_text(code): return f'{shown} ({meaning})' if meaning else shown +FATAL_TAIL = 256 << 10 # bytes of the game's log last_fatal reads from its end +FATAL_SHOWN = 240 # characters of a FATAL line the home screen shows; the launcher's log has all of it + + +def last_fatal(game_dir): + """The last FATAL line of the game's latest run, without its "FATAL: ", or ''. The game writes one before it + exits with 70 (runtime/dispatch.c rt_fatal) or 71 (runtime/main.c crash_handler) into logs/nx2navi.log, which + each run starts anew (runtime/main.c log_open). A line this launcher added for an earlier run's end + (note_exit_in_log) ends the search: what stands before it is not this run's.""" + if not game_dir: + return '' + try: + with open(Path(game_dir) / 'logs' / 'nx2navi.log', 'rb') as f: + f.seek(max(0, f.seek(0, 2) - FATAL_TAIL)) + lines = f.read().decode('utf-8', 'replace').splitlines() + except OSError: + return '' + for line in reversed(lines): + if line.startswith('[launcher] the game ended'): + return '' + if line.startswith('FATAL: '): + return line[len('FATAL: '):].strip() + return '' + + def note_exit_in_log(game_dir, text): """Adds the exit code to the end of the game's log, so a log a player sends shows how the run ended even when the process died before it could write a line itself.""" @@ -1347,8 +1372,17 @@ class App: self.root.lift() if code not in (0, None): text = exit_code_text(code) - self.ctx.log(f'the game ended with code {text}') - self.message = f'The game ended with code {text}. Its log is logs\\nx2navi.log in the game folder.' + # 70 and 71 follow a FATAL line of the game's own, which names the error: the player gets that line + # and the launcher's log too, where the exit code alone said only that there was one + fatal = last_fatal(self.game_dir()) if code & 0xFFFFFFFF in (70, 71) else '' + if fatal: + shown = fatal if len(fatal) <= FATAL_SHOWN else fatal[:FATAL_SHOWN].rstrip() + '…' + self.ctx.log(f'the game ended with code {code & 0xFFFFFFFF}; its log says: FATAL: {fatal}') + self.message = (f'The game ended with a fatal error (code {code & 0xFFFFFFFF}): {shown}. Its log is ' + 'logs\\nx2navi.log in the game folder.') + else: + self.ctx.log(f'the game ended with code {text}') + self.message = f'The game ended with code {text}. Its log is logs\\nx2navi.log in the game folder.' note_exit_in_log(self.game_dir(), text) if self.install_staged('the game exited'): return diff --git a/tests/launcher_test.py b/tests/launcher_test.py index 9e0bceac..629f0e6a 100644 --- a/tests/launcher_test.py +++ b/tests/launcher_test.py @@ -3483,6 +3483,67 @@ class WhilePlayingTest(Tmp): App.watch_game(a) a.root.deiconify.assert_called_once() + def ended(self, code, log_text): + """watch_game after the game ended with code, its log holding log_text; (the stand-in window, the log).""" + from launcher.ui.app import App + log = self.tmp / 'logs' / 'nx2navi.log' + log.parent.mkdir(exist_ok=True) + if log_text is not None: + log.write_text(log_text, encoding='utf-8') + a = mock.MagicMock() + a.settings = {'game_dir': str(self.tmp)} + a.game_dir.return_value = str(self.tmp) + a.game_proc = mock.MagicMock(returncode=code) + a.game_proc.poll.return_value = code + a.install_staged.return_value = False + with mock.patch('launcher.ui.app.saves.game_running', return_value=False): + App.watch_game(a) + return a, log + + def test_a_fatal_exit_shows_the_games_fatal_line(self): + """Exit codes 70 and 71 follow a FATAL line in the game's log: the home screen and launcher.log name it.""" + try: + from launcher.ui import app + except ImportError as e: + self.skipTest(f'no window toolkit: {e}') + fatal = 'vk: vkQueueSubmit2 of 3 held submissions up to value 83931 failed (-4)' + a, log = self.ended(70, f'[host] log: x\nFATAL: an earlier one\n[host] more\nFATAL: {fatal}\n' + '[host] fatal: memory commit: this process 9000 MB, the system 30000 of 32000 MB\n') + self.assertEqual(a.message, f'The game ended with a fatal error (code 70): {fatal}. Its log is ' + 'logs\\nx2navi.log in the game folder.') + a.ctx.log.assert_called_once_with(f'the game ended with code 70; its log says: FATAL: {fatal}') + self.assertIn('[launcher] the game ended with code 70', log.read_text(encoding='utf-8')) + # the crash report's exit code, and a line longer than the home screen shows + long = 'access violation (0xc0000005) at host 0x7ff6 (nx2navi+0x1234), reading address 0x10 ' + 'x' * 300 + a, log = self.ended(71, f'FATAL: {long}\n a line of the report\n') + self.assertIn('(code 71): access violation (0xc0000005)', a.message) + self.assertIn('…. Its log is', a.message) + self.assertLess(len(a.message), app.FATAL_SHOWN + 120) + self.assertIn(long, a.ctx.log.call_args[0][0]) # the launcher's log has the whole line + # a log that still ends with an earlier run's end has no FATAL line of this run + a, _ = self.ended(70, None) + self.assertIn('The game ended with code 70 (a fatal error', a.message) + # any other exit code reads no FATAL line; no log at all is no error + a, log = self.ended(0xC0000005, f'FATAL: {fatal}\n') + self.assertEqual(a.message, 'The game ended with code 0xC0000005 (access violation). Its log is ' + 'logs\\nx2navi.log in the game folder.') + log.unlink() + self.assertEqual(app.last_fatal(self.tmp), '') + self.assertEqual(app.last_fatal(''), '') + + def test_the_fatal_line_is_looked_for_in_the_logs_end(self): + try: + from launcher.ui import app + except ImportError as e: + self.skipTest(f'no window toolkit: {e}') + log = self.tmp / 'logs' / 'nx2navi.log' + log.parent.mkdir() + log.write_bytes(b'FATAL: far back\n' + b'[host] a line\n' * 40000 + b'FATAL: near the end \xff\n[host] last\n') + self.assertGreater(log.stat().st_size, app.FATAL_TAIL) + self.assertEqual(app.last_fatal(self.tmp), 'near the end \ufffd') + log.write_bytes(b'FATAL: far back\n' + b'[host] a line\n' * 40000) + self.assertEqual(app.last_fatal(self.tmp), '') + class MemorySettings(dict): """The launcher's settings (launcher/settings.py) without their file."""