Show the game's FATAL line when it ends with code 70 or 71.

The game exits with 70 after a FATAL line of its own and with 71 after
its crash report, and the launcher said only that the line near the end
of the game's log names the error. It now reads the last FATAL line of
the run from logs\nx2navi.log and puts it into the home screen's message
and into launcher.log, so a launcher log a player sends says what the
error was.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-10 15:29:30 -05:00
parent 18a86dd652
commit 55c7c0f7f2
2 changed files with 97 additions and 2 deletions

View File

@@ -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

View File

@@ -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."""