Let the launcher's bug report export write the game's log and settings when the game has no save yet.

A game that stops before its first save is the one its player most wants to report, and Export Save refused it: "the game has no save yet". With Add Log and Settings on (the default) the launcher now writes NX2navi-log-<date>_<time>-bug-report.zip with the game's latest log, nx2navi.ini and a manifest that counts no save files, and says so on the Saves screen. Without a log there is still nothing to export, an export without the log and settings still needs a save, no account is made for the export, and an import refuses such a zip as one without a save.

This is the first of the two faults pull request #364 names, written anew inside the existing export; its Export Diagnostics screen and its separate crash log are not taken.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-10 14:52:13 -05:00
parent 4a954a1c6d
commit 006bdfa4b2
4 changed files with 76 additions and 8 deletions

View File

@@ -250,7 +250,9 @@ save. Saves (home, under Game Folder; `launcher/saves.py`) and the Saves rows of
**Export.** Export Save writes `NX2navi-save-<date>_<time>.zip` into the Downloads folder (Export To chooses another
folder) and opens the folder with the zip selected, ready to drag into a bug report. With Add Log and Settings on (the
default) it is `...-bug-report.zip` and also holds the game's latest log and its `nx2navi.ini`. In the game, Export
default) it is `...-bug-report.zip` and also holds the game's latest log and its `nx2navi.ini`; while the game has no
save yet (it stopped before its first one) the launcher then writes `NX2navi-log-<date>_<time>-bug-report.zip` with
those two alone, which an import refuses as a zip without a save. In the game, Export
save and Export save for a bug report write the same two zips into Downloads; the second holds the log of the run in
progress. The zip:

View File

@@ -390,15 +390,49 @@ def _fresh_file(folder, stem, suffix):
return p
NO_SAVE = 'the game has no save yet: play until it saves, then export'
def _export_log(game, dest, version, log):
"""The bug report of a game without a save (it stopped before its first one): NX2navi-log-<date>_<time>-bug-report.zip
with the game's latest log, nx2navi.ini and a manifest that counts no save files; import_zip refuses it as a zip
without a save. SaveError when the game has written no log either: then there is nothing to report with."""
extras = [f for f in (game / 'logs' / 'nx2navi.log', game / 'nx2navi.ini') if f.is_file()]
if not extras or extras[0].name != 'nx2navi.log':
raise SaveError(NO_SAVE)
dest.mkdir(parents=True, exist_ok=True)
out = _fresh_file(dest, time.strftime('NX2navi-log-%Y-%m-%d_%H-%M-%S') + '-bug-report', '.zip')
tmp = out.with_name(out.name + '.part')
manifest = {'format': FORMAT, 'format_version': FORMAT_VERSION, 'game': GAME, 'title_id': _title_hex(),
'game_version': GAME_VERSION, 'nx2navi': version,
'exported': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'exported_by': 'launcher',
'files': 0, 'bug_report': True}
try:
with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as z:
z.writestr(MANIFEST, json.dumps(manifest, indent=1) + '\n')
for f in extras:
z.write(f, REPORT_DIR + f.name)
os.replace(tmp, out)
except BaseException:
tmp.unlink(missing_ok=True)
raise
log(f'saves: the game has no save yet; exported {", ".join(f.name for f in extras)} to {out}')
return {'path': out, 'files': 0, 'extras': [f.name for f in extras]}
def export_save(game, dest=None, bug_report=False, version='', log=print):
"""Write the save the game would load now into one zip in dest (default_export_dir when None), for a bug report
or another player. bug_report adds the game's latest log and nx2navi.ini. Never a key file. Returns {'path',
'files', 'extras'} (extras: the names of the bug report files that were there)."""
or another player. bug_report adds the game's latest log and nx2navi.ini, and still writes those two when the game
has no save yet (_export_log): a game that stops before its first save is the one its player wants to report.
Never a key file. Returns {'path', 'files', 'extras'} (files 0: no save; extras: the names of the bug report files
that were there)."""
game = Path(game)
if not (game / 'profile' / 'account.uid').is_file():
raise SaveError('the game has no save yet: play until it saves, then export')
account = account_dir(game)
dest = Path(dest) if dest else default_export_dir()
if not (game / 'profile' / 'account.uid').is_file(): # account_dir would make the account: not for an export
if bug_report:
return _export_log(game, dest, version, log)
raise SaveError(NO_SAVE)
account = account_dir(game)
dest.mkdir(parents=True, exist_ok=True)
stem = time.strftime('NX2navi-save-%Y-%m-%d_%H-%M-%S') + ('-bug-report' if bug_report else '')
extras = [f for f in (game / 'logs' / 'nx2navi.log', game / 'nx2navi.ini') if bug_report and f.is_file()]
@@ -406,7 +440,9 @@ def export_save(game, dest=None, bug_report=False, version='', log=print):
# a running game may swap a commit in while its files are read (runtime/fs_host.c swap_in): read again
current = _newest(game, account)
if current is None or not _has_file(current):
raise SaveError('the game has no save yet: play until it saves, then export')
if bug_report:
return _export_log(game, dest, version, log)
raise SaveError(NO_SAVE)
files = [rel for rel in _files(current, set()) if not _key_file(rel)]
out = _fresh_file(dest, stem, '.zip')
tmp = out.with_name(out.name + '.part')

View File

@@ -1050,7 +1050,8 @@ class App:
Row('value', 'Export Save', out.name or str(out), on=self.export_save, desc=export_desc),
Row('toggle', 'Add Log and Settings', on=report, get=lambda: bool(self.settings.get('export_with_log')),
desc=('On: the zip also holds the game\'s latest log (logs\\nx2navi.log) and its settings '
'(nx2navi.ini), which show what happened and with which settings. Keep it on for a bug report.',
'(nx2navi.ini), which show what happened and with which settings. Keep it on for a bug report: '
'while the game has no save yet, Export Save then writes these two alone.',
'Off: the save alone, to give to another player.')),
Row('value', 'Export To', str(out), on=self.choose_export_dir,
desc=(f'Export Save writes into {out}.', 'Select this row to choose another folder.')),
@@ -1088,6 +1089,9 @@ class App:
else:
extra = ' with the game\'s log and settings' if r['extras'] else ''
self.export_message = f'Exported {r["files"]} files{extra} to {r["path"]}.'
if not r['files']: # a game that stopped before its first save: the report files alone
self.export_message = (f'The game has no save yet: exported its log and settings alone to '
f'{r["path"]}.')
try:
winsys.reveal(r['path'])
except OSError:

View File

@@ -2554,6 +2554,32 @@ class SaveShareTest(unittest.TestCase):
saves.export_save(self.game, self.out, log=lambda m: None) # an account without files
self.assertEqual(list(self.out.iterdir()), [])
def test_a_bug_report_without_a_save_holds_the_log_and_settings(self):
"""A game that stops before its first save: the bug report export writes its log and settings alone."""
with self.assertRaises(saves.SaveError):
saves.export_save(self.game, self.out, bug_report=True, log=lambda m: None) # no log either: nothing
self.assertFalse(self.out.exists() and list(self.out.iterdir()))
self.files(self.game, {'logs/nx2navi.log': b'FATAL: it stopped', 'nx2navi.ini': b'[x]', 'prod.keys': b'secret'})
with self.assertRaises(saves.SaveError):
saves.export_save(self.game, self.out, log=lambda m: None) # not a bug report: a save or nothing
for account in (False, True): # a game that never made its account, and an account without files
if account:
saves.account_dir(self.game).mkdir(parents=True)
with self.subTest(account=account):
r = saves.export_save(self.game, self.out, bug_report=True, version='0.7.1', log=lambda m: None)
self.assertRegex(r['path'].name, r'^NX2navi-log-.*-bug-report( \(\d+\))?\.zip$') # (2): the same second
self.assertEqual((r['files'], r['extras']), (0, ['nx2navi.log', 'nx2navi.ini']))
with zipfile.ZipFile(r['path']) as z:
self.assertEqual(sorted(z.namelist()), ['bug-report/nx2navi.ini', 'bug-report/nx2navi.log',
'manifest.json'])
self.assertEqual(z.read('bug-report/nx2navi.log'), b'FATAL: it stopped')
m = json.loads(z.read('manifest.json'))
self.assertEqual((m['format'], m['files'], m['bug_report'], m['nx2navi']),
(saves.FORMAT, 0, True, '0.7.1'))
with self.assertRaises(saves.SaveError): # nobody's save: an import says so and changes nothing
saves.import_zip(self.game_with_save(self.tmp / f'other{account}'), r['path'], log=lambda m: None)
self.assertFalse(list(self.out.glob('*.part')))
def test_a_shared_save_replaces_the_save_and_keeps_a_backup(self):
other = self.game_with_save(self.tmp / 'other')
shared = saves.export_save(other, self.out, bug_report=True, version='0.6.1', log=lambda m: None)['path']