diff --git a/docs/launcher.md b/docs/launcher.md index 05470cc5..0bcd0729 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -240,7 +240,11 @@ The stub swaps `python.next` in while no Python runs, then starts the new versio objects of 0.5.0 and earlier); Rebuild the Game compiles it for this processor's (`--retune`), and runs the compile step for that even when nothing else changed (`retune_due`). 6. **Swap In the New Build**: lld links `ocarina.exe.new`, which replaces `ocarina.exe` only once it is complete. A - failed rebuild leaves the old build playable; Rebuild the Game tries again. + failed rebuild leaves the old build playable; Rebuild the Game tries again. A rebuild that lifted again and then + stopped has no finished compile on record (`build.built_version` is empty), but the old exe and the unpacked game + files are there: the launcher still counts the game as installed (`build.installed`), opens on home, where + Rebuild the Game reads "Not Finished", and never asks for the dump again. Begin Installation into a folder whose + game files are already unpacked builds on from them as well (`jobs.InstallJob.args`). 7. **Prepare the Shaders** is skipped once shaders were prepared before (`shader-prepared.bin`): the game keeps using them and translates them again with the new translator in the background (`NX2NAVI_SHADER_CARRY`). With `shader_carry = 0`, or nothing prepared yet, the step prepares them all as at the install. diff --git a/launcher/build.py b/launcher/build.py index 40fa2341..1298c0ea 100644 --- a/launcher/build.py +++ b/launcher/build.py @@ -151,3 +151,24 @@ def built_version(game_dir): def can_rebuild(game_dir): st = build_state(game_dir) return st.get('version') == 2 and all(s in st.get('steps', {}) for s in ('exefs', 'romfs')) + + +def unpacked(game_dir): + """True when the game files a rebuild reads are in the game folder (make_game.py BuiltSource names the same + ones): the unpacked ExeFS, the RomFS image and its index.""" + gd = str(game_dir) + return all(os.path.isfile(os.path.join(gd, *parts)) for parts in + (('exefs', 'main'), ('romfs', 'romfs.bin'), ('romfs', 'index.json'))) + + +def installed(game_dir): + """True when the game folder holds a game the player can start: a finished build, or the exe of the last + finished build beside a build that stopped later. make_game.py drops a step's record when a step before it + runs again, so an update that lifted again and then stopped in the compile (an error, Cancel, a power cut) + leaves state.json without `compile` and built_version says '': the old exe is still there and playable, and + a rebuild continues from the unpacked game files without the dump (can_rebuild).""" + if not game_dir: + return False + if built_version(game_dir): + return True + return os.path.isfile(os.path.join(str(game_dir), 'ocarina.exe')) and can_rebuild(game_dir) diff --git a/launcher/cli.py b/launcher/cli.py index 02eb3bba..9341569b 100644 --- a/launcher/cli.py +++ b/launcher/cli.py @@ -98,7 +98,7 @@ def main(argv=None): if a.cmd == 'continue-update': gd = s.get('game_dir') - return follow(jobs.ContinueUpdateJob(ctx, gd if gd and build.built_version(gd) else '', a.old, + return follow(jobs.ContinueUpdateJob(ctx, gd if build.installed(gd) else '', a.old, s.get('dump'))) if a.cmd == 'tools': diff --git a/launcher/jobs.py b/launcher/jobs.py index 22e17e7d..689c5f59 100644 --- a/launcher/jobs.py +++ b/launcher/jobs.py @@ -301,9 +301,18 @@ class InstallJob(Job): 'scan', 'ready']) self.dump, self.game_dir = str(dump), str(game_dir) + def args(self): + """make_game.py's arguments. A game folder whose game files an earlier run already unpacked and checked + (build.can_rebuild, build.unpacked) is built on from those, for this processor's level as a first build is: + the dump is not read again, so an install that stopped after the RomFS image goes on although the dump + was moved or deleted since, and the 26 GB image is not packed a second time.""" + if build.can_rebuild(self.game_dir) and build.unpacked(self.game_dir): + return build.rebuild_args(self.ctx.layout, self.game_dir, retune=True) + return build.install_args(self.ctx.layout, self.dump, self.game_dir) + def run(self): self.ensure_tools('tools') - self.make_game(build.install_args(self.ctx.layout, self.dump, self.game_dir), + self.make_game(self.args(), {s: s for s in ('check', 'exefs', 'romfs', 'lift', 'compile', 'shaders', 'prompts', 'scan')}) for sid in ('shaders', 'prompts', 'scan'): if self.step(sid) and self.step(sid).state == PENDING: diff --git a/launcher/ui/app.py b/launcher/ui/app.py index 229c63dc..70b4651f 100644 --- a/launcher/ui/app.py +++ b/launcher/ui/app.py @@ -653,9 +653,11 @@ class App: elif a.continue_update is not None and not a.rolled_back: # after a rollback the stub passes the failed version's --continue-update on to this one: the game was # never rebuilt for the new version, so there is nothing to finish, and home tells why the version is back - self.run_job(jobs.ContinueUpdateJob(self.ctx, gd if gd and build.built_version(gd) else '', + self.run_job(jobs.ContinueUpdateJob(self.ctx, gd if build.installed(gd) else '', a.continue_update, self.settings.get('dump')), 'home') - elif gd and build.built_version(gd): + elif build.installed(gd): + # also a game whose last update stopped in the compile (build.installed): home, where Play starts the + # build from before the update and Rebuild the Game finishes it, not the first-run screen and its dump self.show('home') self.after_update_notice() if self.install_staged('a staged update waited for the launcher to start'): @@ -870,11 +872,17 @@ class App: upd = Row('value', 'Updates', f'Version {mine}', 'normal', big=True, icon='refresh', on=lambda: self.check_now(force=True), desc=(f'You have NX2navi {mine}. Select this row to look for a new version.',)) - rebuild_value = f'Built with {versions.display(built)}' if built else 'Not built' + # an update that stopped in the compile left no finished build on record, but the exe of the build before it + unfinished = not built and build.installed(gd) + rebuild_value = (f'Built with {versions.display(built)}' if built else + 'Not Finished' if unfinished else 'Not built') rebuild_style = 'normal' if versions.display(built) == mine else 'salmon' play_desc = ('Start Ocarina of Time. The launcher steps aside while you play and comes back when you quit.',) if built and versions.display(built) != mine: play_desc += (f'The game was built with {versions.display(built)}. Rebuild the Game brings it to {mine}.',) + elif unfinished: + play_desc += (f'The last rebuild of the game did not finish, so Play starts the build from before it. ' + f'Rebuild the Game finishes it and brings the game to {mine}; your dump is not needed.',) if staged and self.settings.get('auto_install'): play_desc += (f'NX2navi {staged["version"]} is downloaded and installs after you quit the game.',) if self.message: diff --git a/tests/launcher_test.py b/tests/launcher_test.py index 597a3fe0..99d0ac36 100644 --- a/tests/launcher_test.py +++ b/tests/launcher_test.py @@ -1215,6 +1215,29 @@ def _nest(flat): return tree +def made_up_game(out, steps=('exefs', 'romfs', 'lift', 'compile', 'shaders'), exe=True, kit='0.6.1'): + """A game folder as tools/make_game.py leaves it, made up without game files: an ExeFS whose main is a header + with the game's build id, a RomFS image of zeros, the exe unless exe is False, and a state.json in which the + game-file steps are current and every other step named has the key of an earlier kit.""" + import make_game + out = Path(out) + for d in ('exefs', 'romfs', 'build-cache'): + (out / d).mkdir(parents=True, exist_ok=True) + main = bytearray(0x100) + main[:4], main[0x40:0x50] = b'NSO0', bytes.fromhex(make_game.MAIN_BUILD_ID) + for n in make_game.EXEFS_FILES: + (out / 'exefs' / n).write_bytes(bytes(main) if n == 'main' else b'x' * 64) + (out / 'romfs' / 'romfs.bin').write_bytes(bytes(16)) + (out / 'romfs' / 'index.json').write_text('{}') + if exe: + (out / 'ocarina.exe').write_bytes(b'MZ') + ident = 'nup X 1 1' + keys = {s: f'{s}-1|{ident}' if s in ('exefs', 'romfs') else f'old-{s}' for s in steps} + (out / 'build-cache' / 'state.json').write_text(json.dumps( + {'version': make_game.STATE_VERSION, 'input': ident, 'kit': kit, 'steps': keys})) + return out + + # Stands in for windres.exe: records its arguments, working directory and temporary folder, opens the .rc and the # files it names from the working directory as windres does, and leaves two temporary files behind as windres does. FAKE_WINDRES = r''' @@ -2290,6 +2313,42 @@ class MakeGameTest(Tmp): self.assertIn('windres failed on runtime/nx2navi.rc: windres: preprocessing failed.', err.getvalue()) self.assertEqual(sorted(p.name for p in obj.iterdir()), ['nx2navi_rc.o']) + def main_rebuild(self, lift, compile_, *more): + """make_game.main() --rebuild in this process on self.out, with the given functions in place of the lift and + the compile. Returns (the SystemExit code or 0, what it printed).""" + buf = io.StringIO() + argv = ['make_game.py', '--rebuild', '--out', str(self.out), '--msys', str(self.msys), *more] + with mock.patch.object(self.mg, 'step_lift', lift), mock.patch.object(self.mg, 'step_compile', compile_), \ + mock.patch.object(self.mg, 'keep_awake', lambda: None), mock.patch.object(sys, 'argv', argv), \ + contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf): + try: + self.mg.main() + return 0, buf.getvalue() + except SystemExit as e: + return e.code, buf.getvalue() + + def test_an_update_that_stops_in_the_compile_keeps_the_game(self): + """An update whose lift ran again drops the compile step's record before the compile runs. When the compile + then stops, the folder still holds the exe of the build before and the unpacked game files: the launcher + counts it as installed (build.installed), not as a folder to install into with the dump.""" + made_up_game(self.out) + self.assertEqual(build.built_version(self.out), '0.6.1') + + def stop(*a, **k): + self.mg.fail('3 files did not compile') + code, _ = self.main_rebuild(lambda out, cache: None, stop) + self.assertEqual(code, 1) + st = json.loads((self.out / 'build-cache' / 'state.json').read_text()) + self.assertEqual(sorted(st['steps']), ['exefs', 'lift', 'romfs']) + self.assertEqual(build.built_version(self.out), '') # no finished build on record + self.assertTrue(build.can_rebuild(self.out)) + self.assertTrue(build.installed(self.out)) + (self.out / 'ocarina.exe').unlink() # a first install that stopped there has nothing to play + self.assertFalse(build.installed(self.out)) + self.assertFalse(build.installed('')) + self.assertFalse(build.installed(None)) + self.assertFalse(build.installed(self.tmp / 'no such folder')) + # ---------------------------------------------------------------- jobs @@ -2357,6 +2416,20 @@ class JobTest(Tmp): self.assertIn('1.1.1', j.error) self.assertEqual(j.step('check').state, jobs.FAILED) + def test_an_install_goes_on_from_the_unpacked_game_files(self): + """Begin Installation into a folder whose game files an earlier run unpacked builds on from them (--rebuild + for this processor's level), so a moved or deleted dump is not asked for; without them it reads the dump.""" + ctx = self.make_ctx('ok') + game = self.tmp / 'game' + first = jobs.InstallJob(ctx, self.tmp / 'g.nup', game).args() + self.assertEqual(first[:2], ['--nup', str(self.tmp / 'g.nup')]) + made_up_game(game, steps=('exefs', 'romfs'), exe=False) + again = jobs.InstallJob(ctx, self.tmp / 'g.nup', game).args() + self.assertEqual(again[:2], ['--rebuild', '--retune']) + self.assertNotIn('--nup', again) + (game / 'romfs' / 'romfs.bin').unlink() # the image is gone: only the dump brings it back + self.assertEqual(jobs.InstallJob(ctx, self.tmp / 'g.nup', game).args()[0], '--nup') + # ---------------------------------------------------------------- the icon @@ -3122,6 +3195,60 @@ class WhilePlayingTest(Tmp): a.root.deiconify.assert_called_once() +class UnfinishedUpdateTest(Tmp): + """A game whose last update stopped in the compile (state.json without the compile step, the exe of the build + before still there): the window opens on home and says how to finish, and an update goes on with the game.""" + + def app(self, game): + try: + from launcher.ui.app import App + except ImportError as e: + self.skipTest(f'no window toolkit: {e}') + a = mock.MagicMock() + a.settings = {'game_dir': str(game), 'auto_check': False} + a.args = mock.Mock(rolled_back='', uninstall=False, continue_update=None) + a.ctx = Context(self.layout) + self.addCleanup(a.ctx._file.close) + a.layout = self.layout + a.install_staged.return_value = False + a.bg.ready.return_value = None + a.bg.staging.return_value = False + a.release = a.check = None + a.check_error = a.message = '' + a.checked_at = 0 + return App, a + + def test_the_window_opens_on_home(self): + game = made_up_game(self.tmp / 'game', steps=('exefs', 'romfs', 'lift')) + App, a = self.app(game) + App.start(a) + a.show.assert_called_once_with('home') + a.find_dumps.assert_not_called() + (game / 'ocarina.exe').unlink() # nothing to play: an install that never finished asks for the dump + App, a = self.app(game) + App.start(a) + a.show.assert_called_once_with('first_run') + + def test_an_update_goes_on_with_the_game(self): + game = made_up_game(self.tmp / 'game', steps=('exefs', 'romfs', 'lift')) + App, a = self.app(game) + a.args.continue_update = '0.6.1' + App.start(a) + job = a.run_job.call_args[0][0] + self.assertIsInstance(job, jobs.ContinueUpdateJob) + self.assertEqual(job.game_dir, str(game)) # not '', which skips the rebuild with "No game yet" + + def test_home_says_how_to_finish(self): + game = made_up_game(self.tmp / 'game', steps=('exefs', 'romfs', 'lift')) + App, a = self.app(game) + rows = {r.label: r for r in App.rows_home(a) if r.label} + self.assertEqual(rows['Rebuild the Game'].value, 'Not Finished') + self.assertTrue(any('Rebuild the Game finishes it' in d and 'dump is not needed' in d for d in rows['Play'].desc)) + made_up_game(game) # a finished build of an earlier version + rows = {r.label: r for r in App.rows_home(a) if r.label} + self.assertEqual(rows['Rebuild the Game'].value, 'Built with 0.6.1') + + class WindowTest(Tmp): def test_selftest(self): r = subprocess.run([sys.executable, '-m', 'launcher', '--root', str(self.tmp / 'w'), '--selftest'],